diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 00a8b8eb691..92c7012b7ea 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -18,12 +18,14 @@ ignores: - 'typedoc' # Ignore plugins for tools - '@typescript-eslint/*' - - 'babel-jest' - 'eslint-config-*' - 'eslint-plugin-*' - 'jest-silent-reporter' - 'prettier-plugin-packagejson' - 'typescript-eslint' + # Jest environment referenced in `jest.config.scripts.js` + - 'jest-environment-node' + - 'jest-environment-jsdom' # Ignore dependencies imported implicitly by tools - 'eslint-import-resolver-typescript' # Ignore dependencies which plug into the NPM lifecycle diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 18c0c5a3f8e..78509a1140f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -49,6 +49,7 @@ /packages/notification-services-controller @MetaMask/notifications ## Perps Team +/packages/compliance-controller @MetaMask/perps /packages/perps-controller @MetaMask/perps ## Product Safety Team @@ -108,6 +109,7 @@ /packages/permission-log-controller @MetaMask/wallet-integrations @MetaMask/core-platform /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform @@ -158,6 +160,8 @@ /packages/name-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/notification-services-controller/package.json @MetaMask/notifications @MetaMask/core-platform /packages/notification-services-controller/CHANGELOG.md @MetaMask/notifications @MetaMask/core-platform +/packages/compliance-controller/package.json @MetaMask/perps @MetaMask/core-platform +/packages/compliance-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform /packages/perps-controller/package.json @MetaMask/perps @MetaMask/core-platform /packages/perps-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform /packages/phishing-controller/package.json @MetaMask/product-safety @MetaMask/core-platform @@ -203,4 +207,6 @@ /packages/claims-controller/package.json @MetaMask/web3auth @MetaMask/core-platform /packages/claims-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform /packages/ai-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform -/packages/ai-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform \ No newline at end of file +/packages/ai-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform +/packages/client-controller/package.json @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/client-controller/CHANGELOG.md @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index be39f60d09f..624186be0b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,7 +151,7 @@ Each consumer-facing change to a package should be accompanied by one or more en - Fixed - Security - Within a category section, follow these guidelines: - - Highlight breaking changes by prefixing them with `**BREAKING:**`. List breaking changes above non-breaking changes in the same category section. + - Highlight breaking changes by prefixing them with `**BREAKING:**`. List breaking changes above non-breaking changes in the same category section. A change is breaking if it removes, renames, or changes the signature of any public export (function, type, class, constant), or changes default behavior that consumers rely on. - Omit non-consumer facing changes and reverted changes from the changelog. - Use a nested list to add more details about the change if it would help engineers. For breaking changes in particular, highlight steps engineers need to take to adapt to the changes. - Each changelog entry should be followed by links to the pull request(s) that introduced the change. diff --git a/README.md b/README.md index 1efed86de9d..c66c0afce8a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Each package in this repository has its own README where you can find installati - [`@metamask/build-utils`](packages/build-utils) - [`@metamask/chain-agnostic-permission`](packages/chain-agnostic-permission) - [`@metamask/claims-controller`](packages/claims-controller) +- [`@metamask/client-controller`](packages/client-controller) +- [`@metamask/compliance-controller`](packages/compliance-controller) - [`@metamask/composable-controller`](packages/composable-controller) - [`@metamask/connectivity-controller`](packages/connectivity-controller) - [`@metamask/controller-utils`](packages/controller-utils) @@ -115,6 +117,8 @@ linkStyle default opacity:0.5 build_utils(["@metamask/build-utils"]); chain_agnostic_permission(["@metamask/chain-agnostic-permission"]); claims_controller(["@metamask/claims-controller"]); + client_controller(["@metamask/client-controller"]); + compliance_controller(["@metamask/compliance-controller"]); composable_controller(["@metamask/composable-controller"]); connectivity_controller(["@metamask/connectivity-controller"]); controller_utils(["@metamask/controller-utils"]); @@ -181,31 +185,48 @@ linkStyle default opacity:0.5 address_book_controller --> base_controller; address_book_controller --> controller_utils; address_book_controller --> messenger; + ai_controllers --> base_controller; + ai_controllers --> messenger; analytics_controller --> base_controller; analytics_controller --> messenger; + analytics_data_regulation_controller --> base_controller; + analytics_data_regulation_controller --> controller_utils; + analytics_data_regulation_controller --> messenger; announcement_controller --> base_controller; announcement_controller --> messenger; app_metadata_controller --> base_controller; app_metadata_controller --> messenger; approval_controller --> base_controller; approval_controller --> messenger; + assets_controller --> account_tree_controller; + assets_controller --> assets_controllers; assets_controller --> base_controller; + assets_controller --> controller_utils; + assets_controller --> core_backend; + assets_controller --> keyring_controller; assets_controller --> messenger; + assets_controller --> network_controller; + assets_controller --> network_enablement_controller; + assets_controller --> permission_controller; + assets_controller --> polling_controller; + assets_controller --> preferences_controller; + assets_controller --> transaction_controller; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; assets_controllers --> base_controller; assets_controllers --> controller_utils; - assets_controllers --> core_backend; assets_controllers --> keyring_controller; assets_controllers --> messenger; assets_controllers --> multichain_account_service; assets_controllers --> network_controller; + assets_controllers --> network_enablement_controller; assets_controllers --> permission_controller; assets_controllers --> phishing_controller; assets_controllers --> polling_controller; assets_controllers --> preferences_controller; assets_controllers --> profile_sync_controller; + assets_controllers --> storage_service; assets_controllers --> transaction_controller; base_controller --> messenger; base_controller --> json_rpc_engine; @@ -236,6 +257,11 @@ linkStyle default opacity:0.5 claims_controller --> messenger; claims_controller --> keyring_controller; claims_controller --> profile_sync_controller; + client_controller --> base_controller; + client_controller --> messenger; + compliance_controller --> base_controller; + compliance_controller --> controller_utils; + compliance_controller --> messenger; composable_controller --> base_controller; composable_controller --> messenger; composable_controller --> json_rpc_engine; @@ -412,8 +438,6 @@ linkStyle default opacity:0.5 subscription_controller --> polling_controller; subscription_controller --> profile_sync_controller; subscription_controller --> transaction_controller; - token_search_discovery_controller --> base_controller; - token_search_discovery_controller --> messenger; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; transaction_controller --> base_controller; diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index d9111d1132d..00000000000 --- a/babel.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// We use Babel for our tests in scripts/. -module.exports = { - env: { - test: { - presets: ['@babel/preset-typescript'], - plugins: ['@babel/plugin-transform-modules-commonjs'], - }, - }, -}; diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 99a31f77365..47eeb2b5bab 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -249,9 +249,6 @@ }, "id-denylist": { "count": 1 - }, - "import-x/namespace": { - "count": 9 } }, "packages/assets-controllers/src/NftController.ts": { @@ -341,9 +338,6 @@ }, "id-denylist": { "count": 2 - }, - "import-x/namespace": { - "count": 7 } }, "packages/assets-controllers/src/TokenRatesController.test.ts": { @@ -372,9 +366,6 @@ "packages/assets-controllers/src/TokensController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 6 - }, - "import-x/namespace": { - "count": 1 } }, "packages/assets-controllers/src/TokensController.ts": { @@ -543,14 +534,6 @@ "count": 3 } }, - "packages/bridge-controller/src/bridge-controller.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 2 - }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 1 - } - }, "packages/bridge-controller/src/types.ts": { "@typescript-eslint/naming-convention": { "count": 12 @@ -599,17 +582,6 @@ "count": 1 } }, - "packages/bridge-controller/src/utils/fetch.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 8 - }, - "id-length": { - "count": 1 - } - }, "packages/bridge-controller/src/utils/metrics/properties.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 5 @@ -666,37 +638,15 @@ } }, "packages/bridge-status-controller/src/bridge-status-controller.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 15 - }, "no-new": { "count": 1 } }, - "packages/bridge-status-controller/src/utils/bridge-status.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 3 - }, - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 5 - } - }, "packages/bridge-status-controller/src/utils/gas.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 4 } }, - "packages/bridge-status-controller/src/utils/metrics.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 3 - }, - "@typescript-eslint/naming-convention": { - "count": 4 - }, - "camelcase": { - "count": 8 - } - }, "packages/bridge-status-controller/src/utils/snaps.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 @@ -992,9 +942,6 @@ }, "@typescript-eslint/naming-convention": { "count": 1 - }, - "import-x/namespace": { - "count": 2 } }, "packages/gas-fee-controller/src/GasFeeController.ts": { @@ -1403,9 +1350,6 @@ "packages/phishing-controller/src/utils.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 - }, - "import-x/namespace": { - "count": 5 } }, "packages/phishing-controller/src/utils.ts": { diff --git a/jest.config.packages.js b/jest.config.packages.js index 55c7d9aeeb0..f7729cf40d3 100644 --- a/jest.config.packages.js +++ b/jest.config.packages.js @@ -89,6 +89,7 @@ module.exports = { // so in that case use their published versions '/../../node_modules/@metamask/$1', ], + '^uuid$': require.resolve('uuid'), }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader @@ -188,9 +189,6 @@ module.exports = { // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href // testURL: "http://localhost", - // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" - // timers: "real", - // A map from regular expressions to paths to transformers // transform: undefined, diff --git a/jest.config.scripts.js b/jest.config.scripts.js index cb984e727e2..5ee8419d73f 100644 --- a/jest.config.scripts.js +++ b/jest.config.scripts.js @@ -1,11 +1,6 @@ /* * For a detailed explanation regarding each configuration property and type check, visit: * https://jestjs.io/docs/configuration - * - * NOTE: - * This config uses `babel-jest` due to ESM- / TypeScript-related incompatibilities with our - * current version (`^27`) of `jest` and `ts-jest`. We can switch to `ts-jest` once we have - * migrated our Jest dependencies to version `>=29`. */ module.exports = { @@ -38,17 +33,11 @@ module.exports = { }, }, - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // This ensures that Babel can resolve subpath exports correctly. moduleNameMapper: { - '^@metamask/utils/(.+)$': [ - '/node_modules/@metamask/utils/dist/$1.cjs', - ], + '^uuid$': require.resolve('uuid'), }, - // Disabled due to use of 'transform' below. - // // A preset that is used as a base for Jest's configuration - // preset: 'ts-jest', + 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 @@ -84,9 +73,4 @@ module.exports = { // Default timeout of a test in milliseconds. testTimeout: 5000, - - // A map from regular expressions to paths to transformers - transform: { - '\\.[jt]sx?$': 'babel-jest', - }, }; diff --git a/package.json b/package.json index a7f311d9904..6c67356c476 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "803.0.0", + "version": "822.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { @@ -25,7 +25,7 @@ "lint": "yarn lint:eslint && echo && yarn lint:misc --check && yarn constraints && yarn lint:dependencies && yarn lint:teams && yarn generate-method-action-types --check", "lint:dependencies": "depcheck && yarn dedupe --check", "lint:dependencies:fix": "depcheck && yarn dedupe", - "lint:eslint": "yarn build:only-clean && yarn eslint", + "lint:eslint": "yarn build:only-clean && NODE_OPTIONS='--max-old-space-size=6144' yarn eslint", "lint:fix": "yarn lint:eslint --fix --prune-suppressions && echo && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix && yarn generate-method-action-types --fix", "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path .gitignore", "lint:teams": "tsx scripts/lint-teams-json.ts", @@ -50,9 +50,6 @@ "ws@7.4.6": "^7.5.10" }, "devDependencies": { - "@babel/core": "^7.23.5", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/preset-typescript": "^7.23.3", "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/create-release-branch": "^4.1.4", @@ -66,14 +63,13 @@ "@metamask/network-controller": "^29.0.0", "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", "@types/semver": "^7", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", "@yarnpkg/types": "^4.0.0", - "babel-jest": "^29.7.0", "comment-json": "^4.5.1", "depcheck": "^1.4.7", "eslint": "^9.39.1", @@ -87,7 +83,7 @@ "eslint-plugin-promise": "^7.1.0", "execa": "^5.0.0", "isomorphic-fetch": "^3.0.0", - "jest": "^27.5.1", + "jest": "^29.7.0", "jest-silent-reporter": "^0.5.0", "lodash": "^4.17.21", "nock": "^13.3.1", @@ -100,6 +96,7 @@ "tsx": "^4.20.5", "typescript": "~5.3.3", "typescript-eslint": "^8.48.0", + "uuid": "^8.3.2", "yargs": "^17.7.2" }, "packageManager": "yarn@4.10.3", diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index 77de0c5b46a..a93b04111ad 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.1] + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/multichain-account-service` from `^6.0.0` to `^7.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) + ## [4.1.0] ### Added @@ -431,7 +438,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5847](https://github.com/MetaMask/core/pull/5847)) - Grouping accounts into 3 main categories: Entropy source, Snap ID, keyring types. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@4.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@4.1.1...HEAD +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@4.1.0...@metamask/account-tree-controller@4.1.1 [4.1.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@4.0.0...@metamask/account-tree-controller@4.1.0 [4.0.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@3.0.0...@metamask/account-tree-controller@4.0.0 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@2.0.0...@metamask/account-tree-controller@3.0.0 diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index e19d6473e35..a1ac9956bd7 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/account-tree-controller", - "version": "4.1.0", + "version": "4.1.1", "description": "Controller to group account together based on some pre-defined rules", "keywords": [ "MetaMask", @@ -48,11 +48,11 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/keyring-controller": "^25.1.0", "@metamask/messenger": "^0.3.0", - "@metamask/multichain-account-service": "^6.0.0", + "@metamask/multichain-account-service": "^7.0.0", "@metamask/profile-sync-controller": "^27.1.0", "@metamask/snaps-controllers": "^17.2.0", "@metamask/snaps-sdk": "^10.3.0", @@ -68,11 +68,11 @@ "@metamask/keyring-api": "^21.5.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "webextension-polyfill": "^0.12.0" diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 49f74f6894b..624e60b2af4 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4359,7 +4359,7 @@ describe('AccountTreeController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -4372,13 +4372,13 @@ describe('AccountTreeController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "accountGroupsMetadata": Object {}, - "accountTree": Object { + { + "accountGroupsMetadata": {}, + "accountTree": { "selectedAccountGroup": "", - "wallets": Object {}, + "wallets": {}, }, - "accountWalletsMetadata": Object {}, + "accountWalletsMetadata": {}, "hasAccountTreeSyncingSyncedAtLeastOnce": false, } `); @@ -4394,9 +4394,9 @@ describe('AccountTreeController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "accountGroupsMetadata": Object {}, - "accountWalletsMetadata": Object {}, + { + "accountGroupsMetadata": {}, + "accountWalletsMetadata": {}, "hasAccountTreeSyncingSyncedAtLeastOnce": false, } `); @@ -4412,13 +4412,13 @@ describe('AccountTreeController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "accountGroupsMetadata": Object {}, - "accountTree": Object { + { + "accountGroupsMetadata": {}, + "accountTree": { "selectedAccountGroup": "", - "wallets": Object {}, + "wallets": {}, }, - "accountWalletsMetadata": Object {}, + "accountWalletsMetadata": {}, "hasAccountTreeSyncingSyncedAtLeastOnce": false, "isAccountTreeSyncingInProgress": false, } diff --git a/packages/accounts-controller/CHANGELOG.md b/packages/accounts-controller/CHANGELOG.md index 473d223b096..7c8e91e1c19 100644 --- a/packages/accounts-controller/CHANGELOG.md +++ b/packages/accounts-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [36.0.0] + ### Changed - Bump `@metamask/eth-snap-keyring` from `^18.0.0` to `^19.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) @@ -691,7 +693,8 @@ 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@35.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@36.0.0...HEAD +[36.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@35.0.2...@metamask/accounts-controller@36.0.0 [35.0.2]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@35.0.1...@metamask/accounts-controller@35.0.2 [35.0.1]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@35.0.0...@metamask/accounts-controller@35.0.1 [35.0.0]: https://github.com/MetaMask/core/compare/@metamask/accounts-controller@34.0.0...@metamask/accounts-controller@35.0.0 diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index 00e10fb8f71..207584defd9 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/accounts-controller", - "version": "35.0.2", + "version": "36.0.0", "description": "Manages internal accounts", "keywords": [ "MetaMask", @@ -73,11 +73,11 @@ "@metamask/controller-utils": "^11.18.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/readable-stream": "^2.3.0", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "webextension-polyfill": "^0.12.0" diff --git a/packages/accounts-controller/src/AccountsController.test.ts b/packages/accounts-controller/src/AccountsController.test.ts index 546febe3cfe..30efe029834 100644 --- a/packages/accounts-controller/src/AccountsController.test.ts +++ b/packages/accounts-controller/src/AccountsController.test.ts @@ -3901,7 +3901,7 @@ describe('AccountsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -3914,9 +3914,9 @@ describe('AccountsController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "internalAccounts": Object { - "accounts": Object {}, + { + "internalAccounts": { + "accounts": {}, "selectedAccount": "", }, } @@ -3933,9 +3933,9 @@ describe('AccountsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "internalAccounts": Object { - "accounts": Object {}, + { + "internalAccounts": { + "accounts": {}, "selectedAccount": "", }, } @@ -3952,9 +3952,9 @@ describe('AccountsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "internalAccounts": Object { - "accounts": Object {}, + { + "internalAccounts": { + "accounts": {}, "selectedAccount": "", }, } diff --git a/packages/address-book-controller/package.json b/packages/address-book-controller/package.json index 3d7dbb56a50..cb010afb867 100644 --- a/packages/address-book-controller/package.json +++ b/packages/address-book-controller/package.json @@ -56,11 +56,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/address-book-controller/src/AddressBookController.test.ts b/packages/address-book-controller/src/AddressBookController.test.ts index 675196e73f7..f3064c83750 100644 --- a/packages/address-book-controller/src/AddressBookController.test.ts +++ b/packages/address-book-controller/src/AddressBookController.test.ts @@ -649,7 +649,7 @@ describe('AddressBookController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -662,8 +662,8 @@ describe('AddressBookController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "addressBook": Object {}, + { + "addressBook": {}, } `); }); @@ -678,8 +678,8 @@ describe('AddressBookController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "addressBook": Object {}, + { + "addressBook": {}, } `); }); @@ -694,8 +694,8 @@ describe('AddressBookController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "addressBook": Object {}, + { + "addressBook": {}, } `); }); diff --git a/packages/ai-controllers/CHANGELOG.md b/packages/ai-controllers/CHANGELOG.md index b4d6ef7c5de..eeb7ca91191 100644 --- a/packages/ai-controllers/CHANGELOG.md +++ b/packages/ai-controllers/CHANGELOG.md @@ -7,9 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.0] + ### Added - Initial release ([#7693](https://github.com/MetaMask/core/pull/7693)) - Add `AiDigestController` for fetching and caching AI-generated asset digests ([#7746](https://github.com/MetaMask/core/pull/7746)) +- Add Market Insights support to `AiDigestController` with `fetchMarketInsights` action ([#7930](https://github.com/MetaMask/core/pull/7930)) +- Add `searchDigest` method to `AiDigestService` for calling the GET endpoint (currently mocked) ([#7930](https://github.com/MetaMask/core/pull/7930)) + +### Changed + +- Validate `searchDigest` API responses and throw when the payload does not match the expected `MarketInsightsReport` shape. +- Normalize `searchDigest` responses from either direct report payloads or `digest` envelope payloads. + +### Removed + +- Remove legacy digest APIs and digest cache from `AiDigestController` and `AiDigestService`; only market insights APIs remain. + - Removes `fetchDigest`, `clearDigest`, and `clearAllDigests` actions from the controller action surface. + - Removes `DigestData`/`DigestEntry` types and the `digests` state branch. -[Unreleased]: https://github.com/MetaMask/core/ +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ai-controllers@0.1.0...HEAD +[0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/ai-controllers@0.1.0 diff --git a/packages/ai-controllers/package.json b/packages/ai-controllers/package.json index 98464ee768a..a8d10bb282f 100644 --- a/packages/ai-controllers/package.json +++ b/packages/ai-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/ai-controllers", - "version": "0.0.0", + "version": "0.1.0", "description": "A collection of AI-related controllers", "keywords": [ "MetaMask", @@ -49,16 +49,17 @@ }, "dependencies": { "@metamask/base-controller": "^9.0.0", - "@metamask/messenger": "^0.3.0" + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/ai-controllers/src/AiDigestController.test.ts b/packages/ai-controllers/src/AiDigestController.test.ts index a0003183f13..fc47bb968a6 100644 --- a/packages/ai-controllers/src/AiDigestController.test.ts +++ b/packages/ai-controllers/src/AiDigestController.test.ts @@ -3,175 +3,150 @@ import { Messenger } from '@metamask/messenger'; import { AiDigestController, getDefaultAiDigestControllerState, + AiDigestControllerErrorMessage, CACHE_DURATION_MS, MAX_CACHE_ENTRIES, } from '.'; -import type { AiDigestControllerMessenger } from '.'; - -const mockData = { - id: '123e4567-e89b-12d3-a456-426614174000', - assetId: 'eth-ethereum', - assetSymbol: 'ETH', - digest: 'ETH is trading at $3,245.67 (+2.3% 24h).', - generatedAt: '2026-01-21T10:30:00.000Z', - processingTime: 1523, - success: true, - createdAt: '2026-01-21T10:30:00.000Z', - updatedAt: '2026-01-21T10:30:00.000Z', +import type { + AiDigestControllerMessenger, + DigestService, + MarketInsightsReport, +} from '.'; + +const mockReport: MarketInsightsReport = { + version: '1.0', + asset: 'btc', + generatedAt: '2026-02-11T10:32:52.403Z', + headline: 'BTC update', + summary: 'Momentum remains positive.', + trends: [], + sources: [], }; -const createMessenger = (): AiDigestControllerMessenger => { - return new Messenger({ +const createMessenger = (): AiDigestControllerMessenger => + new Messenger({ namespace: 'AiDigestController', }) as AiDigestControllerMessenger; -}; -describe('AiDigestController', () => { +const createService = (overrides?: Partial): DigestService => ({ + searchDigest: jest.fn().mockResolvedValue(mockReport), + ...overrides, +}); + +describe('AiDigestController (market insights)', () => { it('returns default state', () => { - expect(getDefaultAiDigestControllerState()).toStrictEqual({ digests: {} }); + expect(getDefaultAiDigestControllerState()).toStrictEqual({ + marketInsights: {}, + }); }); - it('fetches and caches a digest', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - }); + it('uses expected cache constants', () => { + expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000); + expect(MAX_CACHE_ENTRIES).toBe(50); + }); - const result = await controller.fetchDigest('ethereum'); + it('registers fetch action on messenger', async () => { + const digestService = createService(); + const messenger = createMessenger(); + const controller = new AiDigestController({ messenger, digestService }); + + const result = await messenger.call( + 'AiDigestController:fetchMarketInsights', + 'eip155:1/slip44:0', + ); - expect(result).toStrictEqual(mockData); - expect(controller.state.digests.ethereum).toBeDefined(); - expect(controller.state.digests.ethereum.data).toStrictEqual(mockData); + expect(result).toStrictEqual(mockReport); + expect(controller.state.marketInsights['eip155:1/slip44:0']).toBeDefined(); }); - it('returns cached digest on subsequent calls', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + it('caches successful response and returns cache while fresh', async () => { + const digestService = createService(); const controller = new AiDigestController({ messenger: createMessenger(), - digestService: mockService, + digestService, }); - await controller.fetchDigest('ethereum'); - await controller.fetchDigest('ethereum'); + await controller.fetchMarketInsights('eip155:1/slip44:0'); + await controller.fetchMarketInsights('eip155:1/slip44:0'); - expect(mockService.fetchDigest).toHaveBeenCalledTimes(1); + expect(digestService.searchDigest).toHaveBeenCalledTimes(1); }); - it('refetches after cache expires', async () => { + it('refetches after cache expiration', async () => { jest.useFakeTimers(); - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const digestService = createService(); const controller = new AiDigestController({ messenger: createMessenger(), - digestService: mockService, + digestService, }); - await controller.fetchDigest('ethereum'); + await controller.fetchMarketInsights('eip155:1/slip44:0'); jest.advanceTimersByTime(CACHE_DURATION_MS + 1); - await controller.fetchDigest('ethereum'); + await controller.fetchMarketInsights('eip155:1/slip44:0'); - expect(mockService.fetchDigest).toHaveBeenCalledTimes(2); + expect(digestService.searchDigest).toHaveBeenCalledTimes(2); jest.useRealTimers(); }); - it('throws on fetch errors', async () => { - const mockService = { - fetchDigest: jest.fn().mockRejectedValue(new Error('Network error')), - }; + it('throws for invalid CAIP asset type', async () => { + const digestService = createService(); const controller = new AiDigestController({ messenger: createMessenger(), - digestService: mockService, + digestService, }); - await expect(controller.fetchDigest('ethereum')).rejects.toThrow( - 'Network error', - ); - expect(controller.state.digests.ethereum).toBeUndefined(); + await expect( + controller.fetchMarketInsights('invalid-caip'), + ).rejects.toThrow(AiDigestControllerErrorMessage.INVALID_CAIP_ASSET_TYPE); + expect(digestService.searchDigest).not.toHaveBeenCalled(); }); - it('clears a specific digest', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + it('removes stale entry when service returns null', async () => { + const digestService = createService(); const controller = new AiDigestController({ messenger: createMessenger(), - digestService: mockService, + digestService, }); - await controller.fetchDigest('ethereum'); - controller.clearDigest('ethereum'); - - expect(controller.state.digests.ethereum).toBeUndefined(); - }); - - it('clears all digests', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - }); - - await controller.fetchDigest('ethereum'); - await controller.fetchDigest('bitcoin'); - controller.clearAllDigests(); + await controller.fetchMarketInsights('eip155:1/slip44:0'); + (digestService.searchDigest as jest.Mock).mockResolvedValue(null); + jest.useFakeTimers(); + jest.advanceTimersByTime(CACHE_DURATION_MS + 1); + const result = await controller.fetchMarketInsights('eip155:1/slip44:0'); + jest.useRealTimers(); - expect(controller.state.digests).toStrictEqual({}); + expect(result).toBeNull(); + expect( + controller.state.marketInsights['eip155:1/slip44:0'], + ).toBeUndefined(); }); - it('evicts stale entries on fetch', async () => { + it('evicts stale and oldest entries', async () => { jest.useFakeTimers(); - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; + const digestService = createService(); const controller = new AiDigestController({ messenger: createMessenger(), - digestService: mockService, + digestService, }); - await controller.fetchDigest('ethereum'); + await controller.fetchMarketInsights('eip155:1/slip44:1'); jest.advanceTimersByTime(CACHE_DURATION_MS + 1); - await controller.fetchDigest('bitcoin'); - - expect(controller.state.digests.ethereum).toBeUndefined(); - expect(controller.state.digests.bitcoin).toBeDefined(); - jest.useRealTimers(); - }); - - it('evicts oldest entries when exceeding max cache size', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; - const controller = new AiDigestController({ - messenger: createMessenger(), - digestService: mockService, - }); + await controller.fetchMarketInsights('eip155:1/slip44:2'); + expect( + controller.state.marketInsights['eip155:1/slip44:1'], + ).toBeUndefined(); for (let i = 0; i < MAX_CACHE_ENTRIES + 1; i++) { - await controller.fetchDigest(`asset${i}`); + await controller.fetchMarketInsights(`eip155:1/slip44:${100 + i}`); + jest.advanceTimersByTime(1); } - expect(Object.keys(controller.state.digests)).toHaveLength( + expect(Object.keys(controller.state.marketInsights)).toHaveLength( MAX_CACHE_ENTRIES, ); - expect(controller.state.digests.asset0).toBeUndefined(); - }); - - it('registers action handlers', async () => { - const mockService = { fetchDigest: jest.fn().mockResolvedValue(mockData) }; - const messenger = createMessenger(); - const controller = new AiDigestController({ - messenger, - digestService: mockService, - }); - - const result = await messenger.call( - 'AiDigestController:fetchDigest', - 'ethereum', - ); - expect(result).toStrictEqual(mockData); - - messenger.call('AiDigestController:clearDigest', 'ethereum'); - messenger.call('AiDigestController:clearAllDigests'); - - expect(controller.state.digests).toStrictEqual({}); - }); - - it('uses expected cache constants', () => { - expect(CACHE_DURATION_MS).toBe(10 * 60 * 1000); - expect(MAX_CACHE_ENTRIES).toBe(50); + expect( + controller.state.marketInsights['eip155:1/slip44:100'], + ).toBeUndefined(); + jest.useRealTimers(); }); }); diff --git a/packages/ai-controllers/src/AiDigestController.ts b/packages/ai-controllers/src/AiDigestController.ts index f215e4412d4..bbe1ef2416b 100644 --- a/packages/ai-controllers/src/AiDigestController.ts +++ b/packages/ai-controllers/src/AiDigestController.ts @@ -5,32 +5,24 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import { isCaipAssetType } from '@metamask/utils'; import { + AiDigestControllerErrorMessage, controllerName, CACHE_DURATION_MS, MAX_CACHE_ENTRIES, } from './ai-digest-constants'; import type { AiDigestControllerState, - DigestEntry, DigestService, - DigestData, + MarketInsightsReport, + MarketInsightsEntry, } from './ai-digest-types'; -export type AiDigestControllerFetchDigestAction = { - type: `${typeof controllerName}:fetchDigest`; - handler: AiDigestController['fetchDigest']; -}; - -export type AiDigestControllerClearDigestAction = { - type: `${typeof controllerName}:clearDigest`; - handler: AiDigestController['clearDigest']; -}; - -export type AiDigestControllerClearAllDigestsAction = { - type: `${typeof controllerName}:clearAllDigests`; - handler: AiDigestController['clearAllDigests']; +export type AiDigestControllerFetchMarketInsightsAction = { + type: `${typeof controllerName}:fetchMarketInsights`; + handler: AiDigestController['fetchMarketInsights']; }; export type AiDigestControllerGetStateAction = ControllerGetStateAction< @@ -39,9 +31,7 @@ export type AiDigestControllerGetStateAction = ControllerGetStateAction< >; export type AiDigestControllerActions = - | AiDigestControllerFetchDigestAction - | AiDigestControllerClearDigestAction - | AiDigestControllerClearAllDigestsAction + | AiDigestControllerFetchMarketInsightsAction | AiDigestControllerGetStateAction; export type AiDigestControllerStateChangeEvent = ControllerStateChangeEvent< @@ -65,12 +55,12 @@ export type AiDigestControllerOptions = { export function getDefaultAiDigestControllerState(): AiDigestControllerState { return { - digests: {}, + marketInsights: {}, }; } const aiDigestControllerMetadata: StateMetadata = { - digests: { + marketInsights: { persist: true, includeInDebugSnapshot: true, includeInStateLogs: true, @@ -102,66 +92,69 @@ export class AiDigestController extends BaseController< #registerMessageHandlers(): void { this.messenger.registerActionHandler( - `${controllerName}:fetchDigest`, - this.fetchDigest.bind(this), - ); - this.messenger.registerActionHandler( - `${controllerName}:clearDigest`, - this.clearDigest.bind(this), - ); - this.messenger.registerActionHandler( - `${controllerName}:clearAllDigests`, - this.clearAllDigests.bind(this), + `${controllerName}:fetchMarketInsights`, + this.fetchMarketInsights.bind(this), ); } - async fetchDigest(assetId: string): Promise { - const existingDigest = this.state.digests[assetId]; - if (existingDigest) { - const age = Date.now() - existingDigest.fetchedAt; + /** + * Fetches market insights for a given CAIP-19 asset identifier. + * Returns cached data if still fresh, otherwise calls the service. + * + * @param caip19Id - The CAIP-19 identifier of the asset. + * @returns The market insights report, or `null` if none exists. + */ + async fetchMarketInsights( + caip19Id: string, + ): Promise { + if (!isCaipAssetType(caip19Id)) { + throw new Error(AiDigestControllerErrorMessage.INVALID_CAIP_ASSET_TYPE); + } + + const existing = this.state.marketInsights[caip19Id]; + if (existing) { + const age = Date.now() - existing.fetchedAt; if (age < CACHE_DURATION_MS) { - return existingDigest.data; + return existing.data; } } - const data = await this.#digestService.fetchDigest(assetId); + const data = await this.#digestService.searchDigest(caip19Id); + + if (data === null) { + // No insights available for this asset — clear any stale cache entry + this.update((state) => { + delete state.marketInsights[caip19Id]; + }); + return null; + } - const entry: DigestEntry = { - asset: assetId, + const entry: MarketInsightsEntry = { + caip19Id, fetchedAt: Date.now(), data, }; this.update((state) => { - state.digests[assetId] = entry; - this.#evictStaleEntries(state); + state.marketInsights[caip19Id] = entry; + this.#evictStaleCachedEntries(state.marketInsights); }); return data; } - clearDigest(assetId: string): void { - this.update((state) => { - delete state.digests[assetId]; - }); - } - - clearAllDigests(): void { - this.update((state) => { - state.digests = {}; - }); - } - /** * Evicts stale (TTL expired) and oldest entries (FIFO) if cache exceeds max size. * - * @param state - The current controller state to evict entries from. + * @param cache - The cache record to evict entries from. */ - #evictStaleEntries(state: AiDigestControllerState): void { + #evictStaleCachedEntries( + cache: Record, + ): void { const now = Date.now(); - const entries = Object.entries(state.digests); + const entries = Object.entries(cache); const keysToDelete: string[] = []; - const freshEntries: [string, DigestEntry][] = []; + const freshEntries: [string, EntryType][] = []; for (const [key, entry] of entries) { if (now - entry.fetchedAt >= CACHE_DURATION_MS) { @@ -171,7 +164,6 @@ export class AiDigestController extends BaseController< } } - // Evict oldest entries if over max cache size if (freshEntries.length > MAX_CACHE_ENTRIES) { freshEntries.sort((a, b) => a[1].fetchedAt - b[1].fetchedAt); const entriesToRemove = freshEntries.length - MAX_CACHE_ENTRIES; @@ -181,7 +173,7 @@ export class AiDigestController extends BaseController< } for (const key of keysToDelete) { - delete state.digests[key]; + delete cache[key]; } } } diff --git a/packages/ai-controllers/src/AiDigestService.test.ts b/packages/ai-controllers/src/AiDigestService.test.ts index 93a60c24f25..f078505ef36 100644 --- a/packages/ai-controllers/src/AiDigestService.test.ts +++ b/packages/ai-controllers/src/AiDigestService.test.ts @@ -1,16 +1,7 @@ -import { AiDigestService } from '.'; +import type { CaipAssetType } from '@metamask/utils'; -const mockDigestResponse = { - id: '123e4567-e89b-12d3-a456-426614174000', - assetId: 'eth-ethereum', - assetSymbol: 'ETH', - digest: 'ETH is trading at $3,245.67 (+2.3% 24h).', - generatedAt: '2026-01-21T10:30:00.000Z', - processingTime: 1523, - success: true, - createdAt: '2026-01-21T10:30:00.000Z', - updatedAt: '2026-01-21T10:30:00.000Z', -}; +import { AiDigestService } from '.'; +import { AiDigestControllerErrorMessage } from './ai-digest-constants'; describe('AiDigestService', () => { const mockFetch = jest.fn(); @@ -25,64 +16,144 @@ describe('AiDigestService', () => { global.fetch = originalFetch; }); - it('fetches latest digest from API', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve(mockDigestResponse), + describe('searchDigest', () => { + const mockMarketInsightsReport = { + asset: 'btc', + generatedAt: '2026-02-16T10:00:00.000Z', + headline: 'BTC market update', + summary: 'Momentum is positive across major venues.', + trends: [], + sources: [], + }; + + it('fetches market insights from API using caipAssetType', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockMarketInsightsReport), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.searchDigest( + 'eip155:1/erc20:0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' as CaipAssetType, + ); + + expect(result).toStrictEqual(mockMarketInsightsReport); + expect(mockFetch).toHaveBeenCalledWith( + 'http://test.com/api/v1/digests?caipAssetType=eip155%3A1%2Ferc20%3A0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + ); }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); - const result = await service.fetchDigest('eth-ethereum'); + it('accepts digest envelope responses', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + digest: mockMarketInsightsReport, + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.searchDigest( + 'eip155:1/erc20:0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' as CaipAssetType, + ); + + expect(result).toStrictEqual(mockMarketInsightsReport); + }); - expect(result).toStrictEqual(mockDigestResponse); - expect(mockFetch).toHaveBeenCalledWith( - `http://test.com/digests/assets/${encodeURIComponent('eth-ethereum')}/latest`, - ); - }); + it('returns null when API returns 404', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); - it('throws on non-ok response', async () => { - mockFetch.mockResolvedValue({ ok: false, status: 500 }); + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + const result = await service.searchDigest( + 'eip155:1/erc20:0xunknown' as CaipAssetType, + ); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + expect(result).toBeNull(); + }); - await expect(service.fetchDigest('eth-ethereum')).rejects.toThrow( - 'API request failed: 500', - ); - }); + it('throws on non-404 non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); - it('throws on unsuccessful response', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - ...mockDigestResponse, - success: false, - error: 'Asset not found', - }), - }); + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + await expect( + service.searchDigest('eip155:1/erc20:0xdeadbeef' as CaipAssetType), + ).rejects.toThrow('API request failed: 500'); + }); - await expect(service.fetchDigest('invalid-asset')).rejects.toThrow( - 'Asset not found', - ); - }); + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + asset: 'btc', + generatedAt: '2026-02-16T10:00:00.000Z', + headline: 'BTC market update', + summary: 'Momentum is positive across major venues.', + trends: 'invalid-trends', + sources: [], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + + await expect( + service.searchDigest('eip155:1/erc20:0xdeadbeef' as CaipAssetType), + ).rejects.toThrow(AiDigestControllerErrorMessage.API_INVALID_RESPONSE); + }); - it('throws default error when no error message provided', async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: () => - Promise.resolve({ - ...mockDigestResponse, - success: false, - error: undefined, - }), + it('throws when version exists but is not a string', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + version: 1, + asset: 'btc', + generatedAt: '2026-02-16T10:00:00.000Z', + headline: 'BTC market update', + summary: 'Momentum is positive across major venues.', + trends: [], + sources: [], + }), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); + + await expect( + service.searchDigest('eip155:1/erc20:0xdeadbeef' as CaipAssetType), + ).rejects.toThrow(AiDigestControllerErrorMessage.API_INVALID_RESPONSE); }); - const service = new AiDigestService({ baseUrl: 'http://test.com' }); + it('throws when response body is not an object', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(null), + }); + + const service = new AiDigestService({ + baseUrl: 'http://test.com/api/v1', + }); - await expect(service.fetchDigest('invalid-asset')).rejects.toThrow( - 'API returned error', - ); + await expect( + service.searchDigest('eip155:1/erc20:0xdeadbeef' as CaipAssetType), + ).rejects.toThrow(AiDigestControllerErrorMessage.API_INVALID_RESPONSE); + }); }); }); diff --git a/packages/ai-controllers/src/AiDigestService.ts b/packages/ai-controllers/src/AiDigestService.ts index da90e19d78f..09b4177e571 100644 --- a/packages/ai-controllers/src/AiDigestService.ts +++ b/packages/ai-controllers/src/AiDigestService.ts @@ -1,10 +1,47 @@ +import type { CaipAssetType } from '@metamask/utils'; + import { AiDigestControllerErrorMessage } from './ai-digest-constants'; -import type { DigestService, DigestData } from './ai-digest-types'; +import type { DigestService, MarketInsightsReport } from './ai-digest-types'; export type AiDigestServiceConfig = { baseUrl: string; }; +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const isMarketInsightsReport = ( + value: unknown, +): value is MarketInsightsReport => { + if (!isObject(value)) { + return false; + } + + return ( + (value.version === undefined || typeof value.version === 'string') && + typeof value.asset === 'string' && + typeof value.generatedAt === 'string' && + typeof value.headline === 'string' && + typeof value.summary === 'string' && + Array.isArray(value.trends) && + Array.isArray(value.sources) + ); +}; + +const getNormalizedMarketInsightsReport = ( + value: unknown, +): MarketInsightsReport | null => { + if (isMarketInsightsReport(value)) { + return value; + } + + if (isObject(value) && isMarketInsightsReport(value.digest)) { + return value.digest; + } + + return null; +}; + export class AiDigestService implements DigestService { readonly #baseUrl: string; @@ -12,25 +49,37 @@ export class AiDigestService implements DigestService { this.#baseUrl = config.baseUrl; } - async fetchDigest(assetId: string): Promise { + /** + * Search for market insights by CAIP-19 asset identifier. + * + * Calls `GET ${this.#baseUrl}/digests?caipAssetType=${encodeURIComponent(caip19Id)}`. + * + * @param caip19Id - The CAIP-19 identifier of the asset. + * @returns The market insights report, or `null` if none exists (404). + */ + async searchDigest( + caip19Id: CaipAssetType, + ): Promise { const response = await fetch( - `${this.#baseUrl}/digests/assets/${encodeURIComponent(assetId)}/latest`, + `${this.#baseUrl}/digests?caipAssetType=${encodeURIComponent(caip19Id)}`, ); + if (response.status === 404) { + return null; + } + if (!response.ok) { throw new Error( `${AiDigestControllerErrorMessage.API_REQUEST_FAILED}: ${response.status}`, ); } - const data: DigestData = await response.json(); + const report = getNormalizedMarketInsightsReport(await response.json()); - if (!data.success) { - throw new Error( - data.error ?? AiDigestControllerErrorMessage.API_RETURNED_ERROR, - ); + if (!report) { + throw new Error(AiDigestControllerErrorMessage.API_INVALID_RESPONSE); } - return data; + return report; } } diff --git a/packages/ai-controllers/src/ai-digest-constants.ts b/packages/ai-controllers/src/ai-digest-constants.ts index a13539f54f1..f65764599f0 100644 --- a/packages/ai-controllers/src/ai-digest-constants.ts +++ b/packages/ai-controllers/src/ai-digest-constants.ts @@ -6,5 +6,6 @@ export const MAX_CACHE_ENTRIES = 50; export const AiDigestControllerErrorMessage = { API_REQUEST_FAILED: 'API request failed', - API_RETURNED_ERROR: 'API returned error', + API_INVALID_RESPONSE: 'API returned invalid response', + INVALID_CAIP_ASSET_TYPE: 'Invalid CAIP asset type', } as const; diff --git a/packages/ai-controllers/src/ai-digest-types.ts b/packages/ai-controllers/src/ai-digest-types.ts index 248a9c383f2..c43adcf5147 100644 --- a/packages/ai-controllers/src/ai-digest-types.ts +++ b/packages/ai-controllers/src/ai-digest-types.ts @@ -1,32 +1,119 @@ +import type { CaipAssetType } from '@metamask/utils'; + +// --------------------------------------------------------------------------- +// Market Insights types +// --------------------------------------------------------------------------- + /** - * Response from the digest API. + * A news article referenced by a market insight trend. */ -export type DigestData = { - id: string; - assetId: string; - assetSymbol?: string; - digest: string; - generatedAt: string; - processingTime: number; - success: boolean; - error?: string; - createdAt: string; - updatedAt: string; +export type MarketInsightsArticle = { + /** Article title */ + title: string; + /** Full URL to the article */ + url: string; + /** Source domain name (e.g. "coindesk.com") */ + source: string; + /** ISO date string */ + date: string; +}; + +/** + * A social media post referenced by a market insight trend. + */ +export type MarketInsightsTweet = { + /** Summary of the tweet content */ + contentSummary: string; + /** Full URL to the tweet */ + url: string; + /** Author handle (e.g. "@saylordocs") */ + author: string; + /** ISO date string */ + date: string; +}; + +/** + * A key market trend identified in the insights report. + */ +export type MarketInsightsTrend = { + /** Trend title (e.g. "Institutions Buying the Dip") */ + title: string; + /** Detailed description of the trend */ + description: string; + /** Category of the trend */ + category: 'macro' | 'technical' | 'social' | string; + /** Impact direction */ + impact: 'positive' | 'negative' | 'neutral' | string; + /** Related news articles */ + articles: MarketInsightsArticle[]; + /** Related social media posts */ + tweets: MarketInsightsTweet[]; }; /** - * A cached digest entry. Only successful fetches are stored. + * A data source used to generate the market insights report. */ -export type DigestEntry = { +export type MarketInsightsSource = { + /** Source name (e.g. "CoinDesk") */ + name: string; + /** Source URL */ + url: string; + /** Source type */ + type: 'news' | 'data' | 'social' | string; +}; + +/** + * AI-generated market insights report for a crypto asset. + * Returned by `GET /digests?caipAssetType=`. + */ +export type MarketInsightsReport = { + /** API version */ + version?: string; + /** Asset symbol (lowercase, e.g. "btc") */ asset: string; + /** ISO date string when the report was generated */ + generatedAt: string; + /** Main headline */ + headline: string; + /** Summary paragraph */ + summary: string; + /** Key market trends */ + trends: MarketInsightsTrend[]; + /** Data sources used to generate the report */ + sources: MarketInsightsSource[]; +}; + +/** + * A cached market insights entry. + */ +export type MarketInsightsEntry = { + /** CAIP-19 asset identifier */ + caip19Id: CaipAssetType; + /** Timestamp when the entry was fetched */ fetchedAt: number; - data: DigestData; + /** The market insights report data */ + data: MarketInsightsReport; }; +// --------------------------------------------------------------------------- +// Controller state +// --------------------------------------------------------------------------- + export type AiDigestControllerState = { - digests: Record; + marketInsights: Record; }; +// --------------------------------------------------------------------------- +// Service interface +// --------------------------------------------------------------------------- + export type DigestService = { - fetchDigest(assetId: string): Promise; + /** + * Search for market insights by CAIP-19 asset identifier. + * Calls `GET /digests?caipAssetType=`. + * + * @param caip19Id - The CAIP-19 identifier of the asset. + * @returns The market insights report, or `null` if no insights exist (404). + */ + searchDigest(caip19Id: CaipAssetType): Promise; }; diff --git a/packages/ai-controllers/src/index.ts b/packages/ai-controllers/src/index.ts index 865becf87bc..491caa95986 100644 --- a/packages/ai-controllers/src/index.ts +++ b/packages/ai-controllers/src/index.ts @@ -1,9 +1,7 @@ export type { AiDigestControllerActions, - AiDigestControllerClearAllDigestsAction, - AiDigestControllerClearDigestAction, AiDigestControllerEvents, - AiDigestControllerFetchDigestAction, + AiDigestControllerFetchMarketInsightsAction, AiDigestControllerGetStateAction, AiDigestControllerMessenger, AiDigestControllerOptions, @@ -19,9 +17,13 @@ export { AiDigestService } from './AiDigestService'; export type { AiDigestControllerState, - DigestData, - DigestEntry, DigestService, + MarketInsightsArticle, + MarketInsightsTweet, + MarketInsightsTrend, + MarketInsightsSource, + MarketInsightsReport, + MarketInsightsEntry, } from './ai-digest-types'; export { diff --git a/packages/analytics-controller/package.json b/packages/analytics-controller/package.json index 33ea2cbd619..822a2043028 100644 --- a/packages/analytics-controller/package.json +++ b/packages/analytics-controller/package.json @@ -55,11 +55,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/analytics-data-regulation-controller/package.json b/packages/analytics-data-regulation-controller/package.json index 3fde6d69ba1..68428c7b58f 100644 --- a/packages/analytics-data-regulation-controller/package.json +++ b/packages/analytics-data-regulation-controller/package.json @@ -56,13 +56,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts index f0e6b23a94e..b528f086391 100644 --- a/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts +++ b/packages/analytics-data-regulation-controller/src/AnalyticsDataRegulationService.test.ts @@ -5,26 +5,23 @@ import type { MessengerEvents, } from '@metamask/messenger'; import nock, { cleanAll, disableNetConnect, enableNetConnect } from 'nock'; -import { useFakeTimers } from 'sinon'; -import type { SinonFakeTimers } from 'sinon'; import type { AnalyticsDataRegulationServiceMessenger } from './AnalyticsDataRegulationService'; import { AnalyticsDataRegulationService } from './AnalyticsDataRegulationService'; import { DATA_DELETE_RESPONSE_STATUSES, DATA_DELETE_STATUSES } from './types'; describe('AnalyticsDataRegulationService', () => { - let clock: SinonFakeTimers; const segmentSourceId = 'test-source-id'; const segmentRegulationsEndpoint = 'https://proxy.example.com/v1beta'; beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); cleanAll(); disableNetConnect(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); cleanAll(); enableNetConnect(); }); @@ -359,7 +356,7 @@ describe('AnalyticsDataRegulationService', () => { const onRetryListener = jest.fn(); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); onRetryListener(); }); @@ -383,7 +380,7 @@ describe('AnalyticsDataRegulationService', () => { const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); }); const onBreakListener = jest.fn(); @@ -418,7 +415,7 @@ describe('AnalyticsDataRegulationService', () => { nock(segmentRegulationsEndpoint) .post(`/regulations/sources/${segmentSourceId}`) .reply(200, () => { - clock.tick(6000); + jest.advanceTimersByTime(6000); return { data: { data: { @@ -448,7 +445,7 @@ describe('AnalyticsDataRegulationService', () => { const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); }); const onDegradedListener = jest.fn(); service.onDegraded(onDegradedListener); diff --git a/packages/announcement-controller/package.json b/packages/announcement-controller/package.json index 0d955b0a884..23bc6b36caa 100644 --- a/packages/announcement-controller/package.json +++ b/packages/announcement-controller/package.json @@ -54,11 +54,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/announcement-controller/src/AnnouncementController.test.ts b/packages/announcement-controller/src/AnnouncementController.test.ts index af387e8b752..6b3a5bbd87b 100644 --- a/packages/announcement-controller/src/AnnouncementController.test.ts +++ b/packages/announcement-controller/src/AnnouncementController.test.ts @@ -182,14 +182,14 @@ describe('announcement controller', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "announcements": Object { - "1": Object { + { + "announcements": { + "1": { "date": "12/8/2020", "id": 1, "isShown": false, }, - "2": Object { + "2": { "date": "12/8/2020", "id": 2, "isShown": false, @@ -212,14 +212,14 @@ describe('announcement controller', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "announcements": Object { - "1": Object { + { + "announcements": { + "1": { "date": "12/8/2020", "id": 1, "isShown": false, }, - "2": Object { + "2": { "date": "12/8/2020", "id": 2, "isShown": false, @@ -242,14 +242,14 @@ describe('announcement controller', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "announcements": Object { - "1": Object { + { + "announcements": { + "1": { "date": "12/8/2020", "id": 1, "isShown": false, }, - "2": Object { + "2": { "date": "12/8/2020", "id": 2, "isShown": false, @@ -272,14 +272,14 @@ describe('announcement controller', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "announcements": Object { - "1": Object { + { + "announcements": { + "1": { "date": "12/8/2020", "id": 1, "isShown": false, }, - "2": Object { + "2": { "date": "12/8/2020", "id": 2, "isShown": false, diff --git a/packages/app-metadata-controller/package.json b/packages/app-metadata-controller/package.json index 91dc7d7dc69..bf5f029e6bf 100644 --- a/packages/app-metadata-controller/package.json +++ b/packages/app-metadata-controller/package.json @@ -54,12 +54,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/app-metadata-controller/src/AppMetadataController.test.ts b/packages/app-metadata-controller/src/AppMetadataController.test.ts index c2d77ee62a0..b05944df221 100644 --- a/packages/app-metadata-controller/src/AppMetadataController.test.ts +++ b/packages/app-metadata-controller/src/AppMetadataController.test.ts @@ -137,7 +137,7 @@ describe('AppMetadataController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "currentAppVersion": "", "currentMigrationVersion": 0, "previousAppVersion": "", @@ -156,7 +156,7 @@ describe('AppMetadataController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "currentAppVersion": "", "currentMigrationVersion": 0, "previousAppVersion": "", @@ -175,7 +175,7 @@ describe('AppMetadataController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "currentAppVersion": "", "currentMigrationVersion": 0, "previousAppVersion": "", @@ -193,7 +193,7 @@ describe('AppMetadataController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/approval-controller/package.json b/packages/approval-controller/package.json index 01c0e2d8227..725c2309020 100644 --- a/packages/approval-controller/package.json +++ b/packages/approval-controller/package.json @@ -57,12 +57,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/approval-controller/src/ApprovalController.test.ts b/packages/approval-controller/src/ApprovalController.test.ts index 639063994c8..687b44328cb 100644 --- a/packages/approval-controller/src/ApprovalController.test.ts +++ b/packages/approval-controller/src/ApprovalController.test.ts @@ -1723,8 +1723,8 @@ describe('approval controller', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "pendingApprovals": Object {}, + { + "pendingApprovals": {}, } `); }); @@ -1737,10 +1737,10 @@ describe('approval controller', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "approvalFlows": Array [], + { + "approvalFlows": [], "pendingApprovalCount": 0, - "pendingApprovals": Object {}, + "pendingApprovals": {}, } `); }); @@ -1752,7 +1752,7 @@ describe('approval controller', () => { approvalController.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -1763,10 +1763,10 @@ describe('approval controller', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "approvalFlows": Array [], + { + "approvalFlows": [], "pendingApprovalCount": 0, - "pendingApprovals": Object {}, + "pendingApprovals": {}, } `); }); diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index faded8ec1a0..b7e550eeb84 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -9,9 +9,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Refactor data source tests to use shared `MockAssetControllerMessenger` fixture ([#7958](https://github.com/MetaMask/core/pull/7958)) + - Export `STAKING_INTERFACE` from the staked balance fetcher for use with the staking contract ABI. + - `StakedBalanceDataSource` teardown now uses the messenger's `clearEventSubscriptions`; custom messenger implementations must support it for correct cleanup. +- Bump `@metamask/network-enablement-controller` from `^4.1.0` to `^4.1.1` ([#7984](https://github.com/MetaMask/core/pull/7984)) + +## [2.0.0] + +### Added + +- Add `StakedBalanceDataSource` that polls supported staking contracts on enabled chains and merges staked balances into `assetsBalance`. Configurable via `stakedBalanceDataSourceConfig` (`enabled`, `pollInterval`); the controller subscribes to it when enabled and cleans up on destroy ([#7936](https://github.com/MetaMask/core/pull/7936)) +- Add optional `trackMetaMetricsEvent` callback to measure and report first init/fetch historical time (duration in ms) to MetaMetrics when the initial asset fetch completes after unlock or app open ([#7871](https://github.com/MetaMask/core/pull/7871)) +- Add `AccountsApiDataSourceConfig` and `PriceDataSourceConfig` types; add `accountsApiDataSourceConfig` and `priceDataSourceConfig` options to `AssetsControllerOptions` for per-data-source configuration (pollInterval, tokenDetectionEnabled, etc.). When `tokenDetectionEnabled` is false, `AccountsApiDataSource` only returns balances for tokens already in state and does not add new tokens ([#7926](https://github.com/MetaMask/core/pull/7926)) +- Add `useExternalService` option to `TokenDetector`, `TokenDetectionOptions`, `RpcDataSourceConfig`, and `RpcDataSourceOptions`. Token detection runs only when both `tokenDetectionEnabled` and `useExternalService` are true and stops when either is false ([#7924](https://github.com/MetaMask/core/pull/7924)) +- Add basic functionality toggle: `isBasicFunctionality` (getter `() => boolean`); no value is stored in the controller. When the getter returns true (matches UI "Basic functionality" ON), token and price APIs are used; when false, only RPC is used. Optional `subscribeToBasicFunctionalityChange(onChange)` lets the consumer register for toggle changes (e.g. extension subscribes to PreferencesController:stateChange, mobile uses its own mechanism); may return an unsubscribe function for controller destroy ([#7904](https://github.com/MetaMask/core/pull/7904)) + +### Changed + +- Refactor `AssetsControllerMessenger` type safety: remove `as unknown as` casts, import types instead of locally defining them, and add missing allowed actions/events ([#7952](https://github.com/MetaMask/core/pull/7952)) +- **BREAKING:** `AccountsApiDataSourceConfig.tokenDetectionEnabled` is now a getter `() => boolean` (was `boolean`) so the Accounts API data source reacts when the user toggles token detection at runtime, consistent with `RpcDataSourceConfig.tokenDetectionEnabled`. Pass a function, e.g. `tokenDetectionEnabled: () => preferenceController.state.useTokenDetection`. +- **BREAKING:** Rename state and `DataResponse` property from `assetsMetadata` to `assetsInfo`. Update consumers that read `state.assetsMetadata` or set `response.assetsMetadata` to use `assetsInfo` instead ([#7902](https://github.com/MetaMask/core/pull/7902)) - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-internal-api` from `^9.0.0` to `^10.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-snap-client` from `^8.0.0` to `^8.2.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) +- Bump `@metamask/account-tree-controller` from `4.1.0` to `4.1.1` ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/core-backend` from `5.1.0` to `5.1.1` ([#7897](https://github.com/MetaMask/core/pull/7897)) ## [1.0.0] @@ -65,7 +87,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@1.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@2.0.0...HEAD +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@1.0.0...@metamask/assets-controller@2.0.0 [1.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@0.2.0...@metamask/assets-controller@1.0.0 [0.2.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@0.1.0...@metamask/assets-controller@0.2.0 [0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/assets-controller@0.1.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index a6712326291..4b4db942e33 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "1.0.0", + "version": "2.0.0", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "MetaMask", @@ -51,21 +51,24 @@ "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/account-tree-controller": "^4.1.0", + "@metamask/account-tree-controller": "^4.1.1", + "@metamask/assets-controllers": "^99.4.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", - "@metamask/core-backend": "^5.1.0", + "@metamask/core-backend": "^5.1.1", "@metamask/keyring-api": "^21.5.0", "@metamask/keyring-controller": "^25.1.0", "@metamask/keyring-internal-api": "^10.0.0", "@metamask/keyring-snap-client": "^8.2.0", "@metamask/messenger": "^0.3.0", "@metamask/network-controller": "^29.0.0", - "@metamask/network-enablement-controller": "^4.1.0", + "@metamask/network-enablement-controller": "^4.1.1", "@metamask/permission-controller": "^12.2.0", "@metamask/polling-controller": "^16.0.2", + "@metamask/preferences-controller": "^22.1.0", "@metamask/snaps-controllers": "^17.2.0", "@metamask/snaps-utils": "^11.7.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0", "bignumber.js": "^9.1.2", @@ -74,12 +77,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.191", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index 283065628a5..b0ffe5a3fd6 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -13,9 +13,11 @@ import { getDefaultAssetsControllerState, } from './AssetsController'; import type { + AssetsControllerFirstInitFetchMetaMetricsPayload, AssetsControllerMessenger, AssetsControllerState, } from './AssetsController'; +import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import type { Caip19AssetId, AccountId } from './types'; function createMockQueryApiClient(): ApiPlatformClient { @@ -55,6 +57,14 @@ function createMockInternalAccount( type WithControllerOptions = { state?: Partial; + isBasicFunctionality?: () => boolean; + /** Extra options passed to AssetsController constructor (e.g. trackMetaMetricsEvent). */ + controllerOptions?: Partial<{ + trackMetaMetricsEvent: ( + payload: AssetsControllerFirstInitFetchMetaMetricsPayload, + ) => void; + priceDataSourceConfig: PriceDataSourceConfig; + }>; }; type WithControllerCallback = ({ @@ -77,7 +87,15 @@ async function withController( | [WithControllerOptions, WithControllerCallback] | [WithControllerCallback] ): Promise { - const [{ state = {} }, fn] = args.length === 2 ? args : [{}, args[0]]; + const [ + { + state = {}, + isBasicFunctionality = (): boolean => true, + controllerOptions = {}, + }, + fn, + ]: [WithControllerOptions, WithControllerCallback] = + args.length === 2 ? args : [{}, args[0]]; // Use root messenger (MOCK_ANY_NAMESPACE) so data sources can register their actions. const messenger: RootMessenger = new Messenger({ @@ -130,6 +148,11 @@ async function withController( messenger: messenger as unknown as AssetsControllerMessenger, state, queryApiClient: createMockQueryApiClient(), + isBasicFunctionality, + subscribeToBasicFunctionalityChange: (): void => { + /* no-op for tests */ + }, + ...controllerOptions, }); return fn({ controller, messenger }); @@ -141,7 +164,7 @@ describe('AssetsController', () => { const defaultState = getDefaultAssetsControllerState(); expect(defaultState).toStrictEqual({ - assetsMetadata: {}, + assetsInfo: {}, assetsBalance: {}, assetsPrice: {}, customAssets: {}, @@ -154,7 +177,7 @@ describe('AssetsController', () => { it('initializes with default state', async () => { await withController(({ controller }) => { expect(controller.state).toStrictEqual({ - assetsMetadata: {}, + assetsInfo: {}, assetsBalance: {}, assetsPrice: {}, customAssets: {}, @@ -165,7 +188,7 @@ describe('AssetsController', () => { it('initializes with provided state', async () => { const initialState: Partial = { - assetsMetadata: { + assetsInfo: { [MOCK_ASSET_ID]: { type: 'erc20', symbol: 'USDC', @@ -178,7 +201,7 @@ describe('AssetsController', () => { }; await withController({ state: initialState }, ({ controller }) => { - expect(controller.state.assetsMetadata[MOCK_ASSET_ID]).toStrictEqual({ + expect(controller.state.assetsInfo[MOCK_ASSET_ID]).toStrictEqual({ type: 'erc20', symbol: 'USDC', name: 'USD Coin', @@ -219,12 +242,15 @@ describe('AssetsController', () => { messenger: messenger as unknown as AssetsControllerMessenger, isEnabled: (): boolean => false, queryApiClient: createMockQueryApiClient(), + subscribeToBasicFunctionalityChange: (): void => { + /* no-op for tests */ + }, }); // Controller should still have default state (from super() call) expect(controller.state).toStrictEqual({ assetPreferences: {}, - assetsMetadata: {}, + assetsInfo: {}, assetsBalance: {}, assetsPrice: {}, customAssets: {}, @@ -245,7 +271,7 @@ describe('AssetsController', () => { // Controller should have default state expect(controller.state).toStrictEqual({ assetPreferences: {}, - assetsMetadata: {}, + assetsInfo: {}, assetsBalance: {}, assetsPrice: {}, customAssets: {}, @@ -260,6 +286,98 @@ describe('AssetsController', () => { }).not.toThrow(); }); }); + + it('accepts accountsApiDataSourceConfig option', () => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + expect( + () => + new AssetsController({ + messenger: messenger as unknown as AssetsControllerMessenger, + isEnabled: (): boolean => false, + queryApiClient: createMockQueryApiClient(), + subscribeToBasicFunctionalityChange: (): void => { + /* no-op */ + }, + accountsApiDataSourceConfig: { + pollInterval: 15_000, + tokenDetectionEnabled: (): boolean => false, + }, + }), + ).not.toThrow(); + }); + + it('accepts priceDataSourceConfig option', () => { + const messenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + ( + messenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('NetworkController:getState', () => ({ + networkConfigurationsByChainId: {}, + networksMetadata: {}, + })); + ( + messenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('NetworkController:getNetworkClientById', () => ({ + provider: {}, + })); + ( + messenger as { + registerActionHandler: (a: string, h: () => unknown) => void; + } + ).registerActionHandler('TokenListController:getState', () => ({ + tokensChainsCache: {}, + })); + + expect( + () => + new AssetsController({ + messenger: messenger as unknown as AssetsControllerMessenger, + isEnabled: (): boolean => false, + queryApiClient: createMockQueryApiClient(), + subscribeToBasicFunctionalityChange: (): void => { + /* no-op */ + }, + priceDataSourceConfig: { + pollInterval: 120_000, + }, + }), + ).not.toThrow(); + }); + + it('accepts isBasicFunctionality option and exposes handleBasicFunctionalityChange', async () => { + await withController(async ({ controller }) => { + expect(controller.handleBasicFunctionalityChange).toBeDefined(); + expect(() => + controller.handleBasicFunctionalityChange(true), + ).not.toThrow(); + }); + }); + + it('works with isBasicFunctionality false (RPC-only mode)', async () => { + await withController( + { state: {}, isBasicFunctionality: () => false }, + async ({ controller }) => { + const accounts = [createMockInternalAccount()]; + const assets = await controller.getAssets(accounts, { + forceUpdate: true, + }); + expect(assets).toBeDefined(); + expect(assets[MOCK_ACCOUNT_ID]).toBeDefined(); + expect(() => + controller.handleBasicFunctionalityChange(false), + ).not.toThrow(); + }, + ); + }); }); describe('addCustomAsset', () => { @@ -387,7 +505,7 @@ describe('AssetsController', () => { describe('getAssetMetadata', () => { it('returns metadata for existing asset', async () => { const initialState: Partial = { - assetsMetadata: { + assetsInfo: { [MOCK_ASSET_ID]: { type: 'erc20', symbol: 'USDC', @@ -533,7 +651,7 @@ describe('AssetsController', () => { await withController(async ({ controller }) => { await controller.handleAssetsUpdate( { - assetsMetadata: { + assetsInfo: { [MOCK_ASSET_ID]: { type: 'erc20', symbol: 'USDC', @@ -545,7 +663,7 @@ describe('AssetsController', () => { 'TestSource', ); - expect(controller.state.assetsMetadata[MOCK_ASSET_ID]).toStrictEqual({ + expect(controller.state.assetsInfo[MOCK_ASSET_ID]).toStrictEqual({ type: 'erc20', symbol: 'USDC', name: 'USD Coin', @@ -691,6 +809,51 @@ describe('AssetsController', () => { expect(true).toBe(true); }); }); + + it('invokes trackMetaMetricsEvent with first init fetch duration on unlock', async () => { + const trackMetaMetricsEvent = jest.fn(); + + await withController( + { controllerOptions: { trackMetaMetricsEvent } }, + async ({ messenger }) => { + messenger.publish('KeyringController:unlock'); + + // Allow #start() -> getAssets() to resolve so the callback runs + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(trackMetaMetricsEvent).toHaveBeenCalledTimes(1); + expect(trackMetaMetricsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + durationMs: expect.any(Number), + chainIds: expect.any(Array), + durationByDataSource: expect.any(Object), + }), + ); + const payload = trackMetaMetricsEvent.mock + .calls[0][0] as AssetsControllerFirstInitFetchMetaMetricsPayload; + expect(payload.durationMs).toBeGreaterThanOrEqual(0); + expect(Array.isArray(payload.chainIds)).toBe(true); + expect(typeof payload.durationByDataSource).toBe('object'); + }, + ); + }); + + it('invokes trackMetaMetricsEvent only once per session until lock', async () => { + const trackMetaMetricsEvent = jest.fn(); + + await withController( + { controllerOptions: { trackMetaMetricsEvent } }, + async ({ messenger }) => { + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + messenger.publish('KeyringController:unlock'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(trackMetaMetricsEvent).toHaveBeenCalledTimes(1); + }, + ); + }); }); describe('subscribeAssetsPrice', () => { diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 4a8decc079a..ba421627e37 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -2,6 +2,7 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, } from '@metamask/account-tree-controller'; +import type { GetTokenListState } from '@metamask/assets-controllers'; import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, @@ -19,12 +20,35 @@ import type { } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerStateChangeEvent, +} from '@metamask/network-controller'; import type { NetworkEnablementControllerGetStateAction, NetworkEnablementControllerEvents, NetworkEnablementControllerState, } from '@metamask/network-enablement-controller'; -import { parseCaipAssetType } from '@metamask/utils'; +import type { + GetPermissions, + PermissionControllerStateChange, +} from '@metamask/permission-controller'; +import type { PreferencesControllerStateChangeEvent } from '@metamask/preferences-controller'; +import type { + GetRunnableSnaps, + HandleSnapRequest, +} from '@metamask/snaps-controllers'; +import type { + TransactionControllerIncomingTransactionsReceivedEvent, + TransactionControllerTransactionConfirmedEvent, +} from '@metamask/transaction-controller'; +import { + isCaipChainId, + isStrictHexString, + parseCaipAssetType, + parseCaipChainId, +} from '@metamask/utils'; import { Mutex } from 'async-mutex'; import BigNumberJS from 'bignumber.js'; import { isEqual } from 'lodash'; @@ -35,12 +59,17 @@ import type { DataSourceState, SubscriptionRequest, } from './data-sources/AbstractDataSource'; +import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource'; import { AccountsApiDataSource } from './data-sources/AccountsApiDataSource'; import { BackendWebsocketDataSource } from './data-sources/BackendWebsocketDataSource'; +import type { PriceDataSourceConfig } from './data-sources/PriceDataSource'; import { PriceDataSource } from './data-sources/PriceDataSource'; import type { RpcDataSourceConfig } from './data-sources/RpcDataSource'; import { RpcDataSource } from './data-sources/RpcDataSource'; +import type { AccountsControllerAccountBalancesUpdatedEvent } from './data-sources/SnapDataSource'; import { SnapDataSource } from './data-sources/SnapDataSource'; +import type { StakedBalanceDataSourceConfig } from './data-sources/StakedBalanceDataSource'; +import { StakedBalanceDataSource } from './data-sources/StakedBalanceDataSource'; import { TokenDataSource } from './data-sources/TokenDataSource'; import { projectLogger, createModuleLogger } from './logger'; import { DetectionMiddleware } from './middlewares/DetectionMiddleware'; @@ -57,6 +86,8 @@ import type { DataType, DataRequest, DataResponse, + FetchContext, + FetchNextFunction, NextFunction, Middleware, SubscriptionResponse, @@ -103,7 +134,7 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); */ export type AssetsControllerState = { /** Shared metadata for all assets (stored once per asset) */ - assetsMetadata: { [assetId: string]: AssetMetadata }; + assetsInfo: { [assetId: string]: AssetMetadata }; /** Per-account balance data */ assetsBalance: { [accountId: string]: { [assetId: string]: AssetBalance } }; /** Price data for assets */ @@ -121,7 +152,7 @@ export type AssetsControllerState = { */ export function getDefaultAssetsControllerState(): AssetsControllerState { return { - assetsMetadata: {}, + assetsInfo: {}, assetsBalance: {}, assetsPrice: {}, customAssets: {}, @@ -176,17 +207,38 @@ export type AssetsControllerEvents = | AssetsControllerAssetsDetectedEvent; type AllowedActions = + // AssetsController | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + // RpcDataSource + | GetTokenListState + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + // RpcDataSource, StakedBalanceDataSource | NetworkEnablementControllerGetStateAction - // BackendWebsocketDataSource calls BackendWebSocketService + // SnapDataSource + | GetRunnableSnaps + | HandleSnapRequest + | GetPermissions + // BackendWebsocketDataSource | BackendWebSocketServiceActions; type AllowedEvents = + // AssetsController | AccountTreeControllerSelectedAccountGroupChangeEvent - | NetworkEnablementControllerEvents - | BackendWebSocketServiceEvents | KeyringControllerLockEvent - | KeyringControllerUnlockEvent; + | KeyringControllerUnlockEvent + | PreferencesControllerStateChangeEvent + // RpcDataSource, StakedBalanceDataSource + | NetworkControllerStateChangeEvent + | TransactionControllerTransactionConfirmedEvent + | TransactionControllerIncomingTransactionsReceivedEvent + // StakedBalanceDataSource + | NetworkEnablementControllerEvents + // SnapDataSource + | AccountsControllerAccountBalancesUpdatedEvent + | PermissionControllerStateChange + // BackendWebsocketDataSource + | BackendWebSocketServiceEvents; export type AssetsControllerMessenger = Messenger< typeof CONTROLLER_NAME, @@ -198,6 +250,23 @@ export type AssetsControllerMessenger = Messenger< // CONTROLLER OPTIONS // ============================================================================ +/** + * Payload for the first init/fetch MetaMetrics event. + * Passed to the optional trackMetaMetricsEvent callback when the initial + * asset fetch completes after unlock or app open. + */ +export type AssetsControllerFirstInitFetchMetaMetricsPayload = { + /** Duration of the first init fetch in milliseconds (wall-clock). */ + durationMs: number; + /** Chain IDs requested in the fetch (e.g. ['eip155:1', 'eip155:137']). */ + chainIds: string[]; + /** + * Exclusive latency in ms per data source (time spent in that source only). + * Sum of values approximates durationMs. Order: same as middleware chain. + */ + durationByDataSource: Record; +}; + export type AssetsControllerOptions = { messenger: AssetsControllerMessenger; state?: Partial; @@ -205,6 +274,25 @@ export type AssetsControllerOptions = { defaultUpdateInterval?: number; /** Function to determine if the controller is enabled. Defaults to true. */ isEnabled?: () => boolean; + /** + * Getter for basic functionality (matches the "Basic functionality" setting in the UI). + * When it returns true, internet services are on: token/price APIs are used for metadata, price, + * and price subscription. When false, only RPC is used (no token/price APIs). + * No value is stored; the getter is invoked when needed. + * Defaults to () => true when not provided (APIs enabled). + */ + isBasicFunctionality?: () => boolean; + /** + * Called by the controller with an onChange callback. The consumer subscribes to its own + * basic-functionality source (e.g. PreferencesController:stateChange in extension, or a + * different mechanism in mobile) and invokes onChange(isBasic) when the value changes. + * The controller will then refresh its subscriptions. May return an unsubscribe function + * called on controller destroy. Optional; when omitted, basic-functionality changes are not + * subscribed to (e.g. host can notify via root messenger or another path). + */ + subscribeToBasicFunctionalityChange?: ( + onChange: (isBasic: boolean) => void, + ) => void | (() => void); /** * API client for balance/price/metadata. The controller instantiates data sources * and uses them directly when this is provided. @@ -212,6 +300,19 @@ export type AssetsControllerOptions = { queryApiClient: ApiPlatformClient; /** Optional configuration for RpcDataSource. */ rpcDataSourceConfig?: RpcDataSourceConfig; + /** + * Optional callback invoked when the first init/fetch completes (e.g. after unlock). + * Use this to track first init fetch duration in MetaMetrics. + */ + trackMetaMetricsEvent?: ( + payload: AssetsControllerFirstInitFetchMetaMetricsPayload, + ) => void; + /** Optional configuration for AccountsApiDataSource. */ + accountsApiDataSourceConfig?: AccountsApiDataSourceConfig; + /** Optional configuration for PriceDataSource. */ + priceDataSourceConfig?: PriceDataSourceConfig; + /** Optional configuration for StakedBalanceDataSource. */ + stakedBalanceDataSourceConfig?: StakedBalanceDataSourceConfig; }; // ============================================================================ @@ -219,7 +320,7 @@ export type AssetsControllerOptions = { // ============================================================================ const stateMetadata: StateMetadata = { - assetsMetadata: { + assetsInfo: { persist: true, includeInStateLogs: false, includeInDebugSnapshot: false, @@ -271,11 +372,11 @@ function extractChainId(assetId: Caip19AssetId): ChainId { function normalizeResponse(response: DataResponse): DataResponse { const normalized: DataResponse = {}; - if (response.assetsMetadata) { - normalized.assetsMetadata = {}; - for (const [assetId, metadata] of Object.entries(response.assetsMetadata)) { + if (response.assetsInfo) { + normalized.assetsInfo = {}; + for (const [assetId, metadata] of Object.entries(response.assetsInfo)) { const normalizedId = normalizeAssetId(assetId as Caip19AssetId); - normalized.assetsMetadata[normalizedId] = metadata; + normalized.assetsInfo[normalizedId] = metadata; } } @@ -357,9 +458,20 @@ export class AssetsController extends BaseController< /** Whether the controller is enabled */ readonly #isEnabled: boolean; + /** Getter for basic functionality (only balance fetch/subscribe use RPC; token/price API not used). No attribute stored. */ + readonly #isBasicFunctionality: () => boolean; + /** Default update interval hint passed to data sources */ readonly #defaultUpdateInterval: number; + /** Optional callback for first init/fetch MetaMetrics (duration). */ + readonly #trackMetaMetricsEvent?: ( + payload: AssetsControllerFirstInitFetchMetaMetricsPayload, + ) => void; + + /** Whether we have already reported first init fetch for this session (reset on #stop). */ + #firstInitFetchReported = false; + readonly #controllerMutex = new Mutex(); /** @@ -394,12 +506,17 @@ export class AssetsController extends BaseController< readonly #rpcDataSource: RpcDataSource; + readonly #stakedBalanceDataSource: StakedBalanceDataSource; + /** - * Subscription balance data sources in assignment priority order (first that supports a chain gets it). + * All balance data sources (used for unsubscription in #stop so we can clean up + * regardless of current isBasicFunctionality mode). + * Note: StakedBalanceDataSource is excluded because it provides supplementary + * data and should not participate in chain-claiming. * * @returns The four balance data source instances in priority order. */ - get #subscriptionBalanceDataSources(): [ + get #allBalanceDataSources(): [ BackendWebsocketDataSource, AccountsApiDataSource, SnapDataSource, @@ -419,13 +536,21 @@ export class AssetsController extends BaseController< readonly #tokenDataSource: TokenDataSource; + #unsubscribeBasicFunctionality: (() => void) | null = null; + constructor({ messenger, state = {}, defaultUpdateInterval = DEFAULT_POLLING_INTERVAL_MS, isEnabled = (): boolean => true, + isBasicFunctionality, + subscribeToBasicFunctionalityChange, queryApiClient, rpcDataSourceConfig, + trackMetaMetricsEvent, + accountsApiDataSourceConfig, + priceDataSourceConfig, + stakedBalanceDataSourceConfig, }: AssetsControllerOptions) { super({ name: CONTROLLER_NAME, @@ -438,15 +563,18 @@ export class AssetsController extends BaseController< }); this.#isEnabled = isEnabled(); + this.#isBasicFunctionality = isBasicFunctionality ?? ((): boolean => true); this.#defaultUpdateInterval = defaultUpdateInterval; + this.#trackMetaMetricsEvent = trackMetaMetricsEvent; const rpcConfig = rpcDataSourceConfig ?? {}; const onActiveChainsUpdated = ( dataSourceName: string, chains: ChainId[], previousChains: ChainId[], - ): void => + ): void => { this.handleActiveChainsUpdate(dataSourceName, chains, previousChains); + }; this.#backendWebsocketDataSource = new BackendWebsocketDataSource({ messenger: this.messenger, @@ -456,6 +584,7 @@ export class AssetsController extends BaseController< this.#accountsApiDataSource = new AccountsApiDataSource({ queryApiClient, onActiveChainsUpdated, + ...accountsApiDataSourceConfig, }); this.#snapDataSource = new SnapDataSource({ messenger: this.messenger, @@ -466,11 +595,17 @@ export class AssetsController extends BaseController< onActiveChainsUpdated, ...rpcConfig, }); + this.#stakedBalanceDataSource = new StakedBalanceDataSource({ + messenger: this.messenger, + onActiveChainsUpdated, + ...stakedBalanceDataSourceConfig, + }); this.#tokenDataSource = new TokenDataSource({ queryApiClient, }); this.#priceDataSource = new PriceDataSource({ queryApiClient, + ...priceDataSourceConfig, }); this.#detectionMiddleware = new DetectionMiddleware(); @@ -486,6 +621,18 @@ export class AssetsController extends BaseController< this.#initializeState(); this.#subscribeToEvents(); this.#registerActionHandlers(); + // Subscriptions start only on KeyringController:unlock -> #start(), not here. + + // Subscribe to basic-functionality changes after construction so a synchronous + // onChange during subscribe cannot run before data sources are initialized. + if (subscribeToBasicFunctionalityChange) { + const unsubscribe = subscribeToBasicFunctionalityChange((isBasic) => + this.handleBasicFunctionalityChange(isBasic), + ); + if (typeof unsubscribe === 'function') { + this.#unsubscribeBasicFunctionality = unsubscribe; + } + } } // ============================================================================ @@ -549,7 +696,7 @@ export class AssetsController extends BaseController< * @returns The normalized chain reference in decimal format. */ #normalizeChainReference(namespace: string, reference: string): string { - if (namespace === 'eip155' && reference.startsWith('0x')) { + if (namespace === 'eip155' && isStrictHexString(reference)) { // Convert hex to decimal for EIP155 chains return parseInt(reference, 16).toString(); } @@ -609,6 +756,9 @@ export class AssetsController extends BaseController< activeChains: ChainId[], previousChains: ChainId[], ): void { + if (!this.#isEnabled) { + return; + } log('Data source active chains changed', { dataSourceId, chainCount: activeChains.length, @@ -649,18 +799,44 @@ export class AssetsController extends BaseController< /** * Execute middlewares with request/response context. + * Returns response and exclusive duration per source (sum ≈ wall time). * - * @param middlewares - Middlewares to execute in order. + * @param sources - Data sources or middlewares with getName() and assetsMiddleware (executed in order). * @param request - The data request. * @param initialResponse - Optional initial response (for enriching existing data). - * @returns The final DataResponse after all middlewares have processed. + * @returns Response and durationByDataSource (exclusive ms per source name). */ async #executeMiddlewares( - middlewares: Middleware[], + sources: { getName(): string; assetsMiddleware: Middleware }[], request: DataRequest, initialResponse: DataResponse = {}, - ): Promise { - const chain = middlewares.reduceRight( + ): Promise<{ + response: DataResponse; + durationByDataSource: Record; + }> { + const names = sources.map((source) => source.getName()); + const middlewares = sources.map((source) => source.assetsMiddleware); + const inclusive: number[] = []; + const wrapped = middlewares.map( + (middleware, i) => + (async ( + ctx: FetchContext, + next: FetchNextFunction, + ): Promise<{ + request: DataRequest; + response: DataResponse; + getAssetsState: () => AssetsControllerStateInternal; + }> => { + const start = Date.now(); + try { + return await middleware(ctx, next); + } finally { + inclusive[i] = Date.now() - start; + } + }) as Middleware, + ); + + const chain = wrapped.reduceRight( (next, middleware) => async ( ctx, @@ -684,7 +860,17 @@ export class AssetsController extends BaseController< response: initialResponse, getAssetsState: () => this.state as AssetsControllerStateInternal, }); - return result.response; + + const durationByDataSource: Record = {}; + for (let i = 0; i < inclusive.length; i++) { + const nextInc = i + 1 < inclusive.length ? (inclusive[i + 1] ?? 0) : 0; + const exclusive = Math.max(0, (inclusive[i] ?? 0) - nextInc); + const name = names[i]; + if (name !== undefined) { + durationByDataSource[name] = exclusive; + } + } + return { response: result.response, durationByDataSource }; } // ============================================================================ @@ -712,24 +898,42 @@ export class AssetsController extends BaseController< } if (options?.forceUpdate) { + const startTime = Date.now(); const request = this.#buildDataRequest(accounts, chainIds, { assetTypes, dataTypes, customAssets: customAssets.length > 0 ? customAssets : undefined, forceUpdate: true, }); - const response = await this.#executeMiddlewares( - [ - this.#accountsApiDataSource.assetsMiddleware, - this.#snapDataSource.assetsMiddleware, - this.#rpcDataSource.assetsMiddleware, - this.#detectionMiddleware.assetsMiddleware, - this.#tokenDataSource.assetsMiddleware, - this.#priceDataSource.assetsMiddleware, - ], + const sources = this.#isBasicFunctionality() + ? [ + this.#accountsApiDataSource, + this.#snapDataSource, + this.#rpcDataSource, + this.#stakedBalanceDataSource, + this.#detectionMiddleware, + this.#tokenDataSource, + this.#priceDataSource, + ] + : [ + this.#rpcDataSource, + this.#stakedBalanceDataSource, + this.#detectionMiddleware, + ]; + const { response, durationByDataSource } = await this.#executeMiddlewares( + sources, request, ); await this.#updateState(response); + if (this.#trackMetaMetricsEvent && !this.#firstInitFetchReported) { + this.#firstInitFetchReported = true; + const durationMs = Date.now() - startTime; + this.#trackMetaMetricsEvent({ + durationMs, + chainIds, + durationByDataSource, + }); + } } return this.#getAssetsFromState(accounts, chainIds, assetTypes); @@ -766,7 +970,7 @@ export class AssetsController extends BaseController< } getAssetMetadata(assetId: Caip19AssetId): AssetMetadata | undefined { - return this.state.assetsMetadata[assetId] as AssetMetadata | undefined; + return this.state.assetsInfo[assetId] as AssetMetadata | undefined; } async getAssetsPrice( @@ -939,15 +1143,11 @@ export class AssetsController extends BaseController< * * @param accounts - Accounts to subscribe price updates for. * @param chainIds - Chain IDs to filter prices for. - * @param options - Subscription options. - * @param options.updateInterval - Polling interval in ms. */ - subscribeAssetsPrice( - accounts: InternalAccount[], - chainIds: ChainId[], - options: { updateInterval?: number } = {}, - ): void { - const { updateInterval = this.#defaultUpdateInterval } = options; + subscribeAssetsPrice(accounts: InternalAccount[], chainIds: ChainId[]): void { + if (!this.#isBasicFunctionality()) { + return; + } const subscriptionKey = 'ds:PriceDataSource'; const existingSubscription = this.#activeSubscriptions.get(subscriptionKey); @@ -956,7 +1156,6 @@ export class AssetsController extends BaseController< const subscribeReq: SubscriptionRequest = { request: this.#buildDataRequest(accounts, chainIds, { dataTypes: ['price'], - updateInterval, }), subscriptionId: subscriptionKey, isUpdate, @@ -1023,22 +1222,19 @@ export class AssetsController extends BaseController< this.update((state) => { // Use type assertions to avoid deep type instantiation issues with Immer Draft types - const metadata = state.assetsMetadata as Record; + const metadata = state.assetsInfo as Record; const balances = state.assetsBalance as Record< string, Record >; const prices = state.assetsPrice as Record; - if (normalizedResponse.assetsMetadata) { + if (normalizedResponse.assetsInfo) { for (const [key, value] of Object.entries( - normalizedResponse.assetsMetadata, + normalizedResponse.assetsInfo, )) { if ( - !isEqual( - previousState.assetsMetadata[key as Caip19AssetId], - value, - ) + !isEqual(previousState.assetsInfo[key as Caip19AssetId], value) ) { changedMetadata.push(key); } @@ -1184,7 +1380,7 @@ export class AssetsController extends BaseController< for (const [assetId, balance] of Object.entries(accountBalances)) { const typedAssetId = assetId as Caip19AssetId; - const metadataRaw = this.state.assetsMetadata[typedAssetId]; + const metadataRaw = this.state.assetsInfo[typedAssetId]; // Skip assets without metadata if (!metadataRaw) { @@ -1280,14 +1476,12 @@ export class AssetsController extends BaseController< }); this.#subscribeAssets(); - if (this.#selectedAccounts.length > 0) { - this.getAssets(this.#selectedAccounts, { - chainIds: [...this.#enabledChains], - forceUpdate: true, - }).catch((error) => { - log('Failed to fetch assets', error); - }); - } + this.getAssets(this.#selectedAccounts, { + chainIds: [...this.#enabledChains], + forceUpdate: true, + }).catch((error) => { + log('Failed to fetch assets', error); + }); } /** @@ -1300,19 +1494,24 @@ export class AssetsController extends BaseController< hasPriceSubscription: this.#activeSubscriptions.has('ds:PriceDataSource'), }); + this.#firstInitFetchReported = false; + // Stop price subscription first (uses direct messenger call) this.unsubscribeAssetsPrice(); // Stop balance subscriptions by properly notifying data sources via messenger - // This ensures data sources stop their polling timers - // Convert to array first to avoid modifying map during iteration + // This ensures data sources stop their polling timers. + // Use #allBalanceDataSources + staked balance source so we unsubscribe from + // every source that may have been subscribed. + const allSources = [ + ...this.#allBalanceDataSources, + this.#stakedBalanceDataSource, + ]; const subscriptionKeys = [...this.#activeSubscriptions.keys()]; for (const subscriptionKey of subscriptionKeys) { if (subscriptionKey.startsWith('ds:')) { const sourceId = subscriptionKey.slice(3); - const source = this.#subscriptionBalanceDataSources.find( - (ds) => ds.getName() === sourceId, - ); + const source = allSources.find((ds) => ds.getName() === sourceId); if (source) { this.#unsubscribeDataSource(source); } @@ -1321,6 +1520,19 @@ export class AssetsController extends BaseController< this.#activeSubscriptions.clear(); } + /** + * Handle basic functionality toggle change. Call this from the consumer (extension or mobile) + * when the user changes the "Basic functionality" setting. Refreshes subscriptions so the + * current {@link AssetsControllerOptions.isBasicFunctionality} getter is used (true = APIs on, + * false = RPC only). + * + * @param _isBasic - The new value (for call-site clarity; the getter is the source of truth). + */ + handleBasicFunctionalityChange(_isBasic: boolean): void { + this.#stop(); + this.#subscribeAssets(); + } + /** * Subscribe to asset updates for all selected accounts. */ @@ -1334,6 +1546,11 @@ export class AssetsController extends BaseController< ...this.#enabledChains, ]); + // Subscribe to staked balance updates (separate from regular balance chain-claiming) + this.#subscribeStakedBalance(this.#selectedAccounts, [ + ...this.#enabledChains, + ]); + // Subscribe to price updates for all assets held by selected accounts this.subscribeAssetsPrice(this.#selectedAccounts, [...this.#enabledChains]); } @@ -1362,7 +1579,12 @@ export class AssetsController extends BaseController< ); const remainingChains = new Set(chainToAccounts.keys()); - for (const source of this.#subscriptionBalanceDataSources) { + // When basic functionality is on (getter true), use all balance data sources; when off (getter false), RPC only. + const balanceDataSources = this.#isBasicFunctionality() + ? this.#allBalanceDataSources + : [this.#rpcDataSource]; + + for (const source of balanceDataSources) { const availableChains = new Set(source.getActiveChainsSync()); const assignedChains: ChainId[] = []; @@ -1391,6 +1613,35 @@ export class AssetsController extends BaseController< } } + /** + * Subscribe to staked balance updates. + * Unlike regular balance data sources, the staked balance data source provides + * supplementary data and does not participate in chain-claiming. + * + * @param accounts - Accounts to subscribe staked balance updates for. + * @param chainIds - Chain IDs to subscribe for. + */ + #subscribeStakedBalance( + accounts: InternalAccount[], + chainIds: ChainId[], + ): void { + const source = this.#stakedBalanceDataSource; + if (!source) { + return; + } + const availableChains = new Set(source.getActiveChainsSync()); + const assignedChains = chainIds.filter((chainId) => + availableChains.has(chainId), + ); + + if (assignedChains.length === 0) { + this.#unsubscribeDataSource(source); + return; + } + + this.#subscribeDataSource(source, accounts, assignedChains); + } + /** * Build a mapping of chainId -> accounts that support that chain. * Only includes chains that are in the chainsToSubscribe set. @@ -1543,16 +1794,24 @@ export class AssetsController extends BaseController< const result: ChainId[] = []; for (const scope of scopes) { - const [namespace, reference] = (scope as string).split(':'); + const scopeStr = scope as string; + if (!isCaipChainId(scopeStr)) { + result.push(scope); + continue; + } + const { namespace, reference } = parseCaipChainId(scopeStr); // Wildcard scope (e.g., "eip155:0" means all enabled chains in that namespace) if (reference === '0') { for (const chain of this.#enabledChains) { - if (chain.startsWith(`${namespace}:`)) { - result.push(chain); + if (isCaipChainId(chain)) { + const chainParsed = parseCaipChainId(chain); + if (chainParsed.namespace === namespace) { + result.push(chain); + } } } - } else if (namespace === 'eip155' && reference?.startsWith('0x')) { + } else if (namespace === 'eip155' && isStrictHexString(reference)) { // Normalize hex to decimal for EIP155 result.push(`eip155:${parseInt(reference, 16)}` as ChainId); } else { @@ -1652,12 +1911,8 @@ export class AssetsController extends BaseController< // Run through enrichment middlewares (Event Stack: Detection → Token → Price) // Include 'metadata' in dataTypes so TokenDataSource runs to enrich detected assets - const enrichedResponse = await this.#executeMiddlewares( - [ - this.#detectionMiddleware.assetsMiddleware, - this.#tokenDataSource.assetsMiddleware, - this.#priceDataSource.assetsMiddleware, - ], + const { response: enrichedResponse } = await this.#executeMiddlewares( + [this.#detectionMiddleware, this.#tokenDataSource, this.#priceDataSource], request ?? { accountsWithSupportedChains: [], chainIds: [], @@ -1675,7 +1930,7 @@ export class AssetsController extends BaseController< destroy(): void { log('Destroying AssetsController', { - dataSourceCount: this.#subscriptionBalanceDataSources.length, + dataSourceCount: this.#allBalanceDataSources.length, subscriptionCount: this.#activeSubscriptions.size, }); @@ -1683,18 +1938,17 @@ export class AssetsController extends BaseController< this.#backendWebsocketDataSource?.destroy?.(); this.#accountsApiDataSource?.destroy?.(); this.#snapDataSource?.destroy?.(); - if ( - this.#rpcDataSource && - 'destroy' in this.#rpcDataSource && - typeof (this.#rpcDataSource as { destroy: () => void }).destroy === - 'function' - ) { - (this.#rpcDataSource as { destroy: () => void }).destroy(); - } + this.#rpcDataSource?.destroy?.(); + this.#stakedBalanceDataSource?.destroy?.(); // Stop all active subscriptions this.#stop(); + if (this.#unsubscribeBasicFunctionality) { + this.#unsubscribeBasicFunctionality(); + this.#unsubscribeBasicFunctionality = null; + } + // Unregister action handlers this.messenger.unregisterActionHandler('AssetsController:getAssets'); this.messenger.unregisterActionHandler('AssetsController:getAssetsBalance'); diff --git a/packages/assets-controller/src/README.md b/packages/assets-controller/src/README.md index 80496f16e26..892cfc16238 100644 --- a/packages/assets-controller/src/README.md +++ b/packages/assets-controller/src/README.md @@ -184,7 +184,7 @@ DataSource calls: messenger.call('AssetsController:assetsUpdate', response, sour │ │ Response contains any combination of: │ ├── assetsBalance - Balance updates - │ ├── assetsMetadata - Metadata updates + │ ├── assetsInfo - Metadata updates │ └── assetsPrice - Price updates │ ├── executeMiddlewares(Event Stack, request, response) @@ -195,7 +195,7 @@ DataSource calls: messenger.call('AssetsController:assetsUpdate', response, sour └── updateState(enrichedResponse) ├── Normalize asset IDs (checksum EVM addresses) ├── Merge into persisted state - │ ├── assetsMetadata[assetId] = metadata + │ ├── assetsInfo[assetId] = metadata │ └── assetsBalance[accountId][assetId] = balance ├── Update in-memory price cache (assetsPrice) │ @@ -289,7 +289,7 @@ destroy() ```typescript { // Shared metadata (stored once per asset) - assetsMetadata: { + assetsInfo: { "eip155:1/slip44:60": { type: "native", symbol: "ETH", ... }, "eip155:1/erc20:0xA0b8...": { type: "erc20", symbol: "USDC", ... }, }, @@ -330,7 +330,7 @@ const state = messenger.call('AssetsController:getState'); ```typescript interface AssetsControllerState { - assetsMetadata: { [assetId: string]: AssetMetadata }; + assetsInfo: { [assetId: string]: AssetMetadata }; assetsBalance: { [accountId: string]: { [assetId: string]: AssetBalance } }; } ``` @@ -562,7 +562,7 @@ messenger.subscribe('AssetsController:stateChange', (state) => { ```typescript { - assetsMetadata: { [assetId: string]: AssetMetadata }; + assetsInfo: { [assetId: string]: AssetMetadata }; assetsBalance: { [accountId: string]: { [assetId: string]: AssetBalance } }; } ``` @@ -713,13 +713,13 @@ class MyController { ```typescript // Selector for getting assets from Redux state export const selectAssetsForAccount = (state, accountId) => { - const { assetsMetadata, assetsBalance } = state.AssetsController; + const { assetsInfo, assetsBalance } = state.AssetsController; const { assetsPrice } = state; // In-memory prices from a separate slice const accountBalances = assetsBalance[accountId] || {}; return Object.entries(accountBalances).map(([assetId, balance]) => { - const metadata = assetsMetadata[assetId]; + const metadata = assetsInfo[assetId]; const price = assetsPrice[assetId] || { price: 0 }; // balance.amount is already in human-readable format (e.g., "1.5" for 1.5 ETH) @@ -909,6 +909,11 @@ import { AssetsController } from '@metamask/assets-controller'; const assetsController = new AssetsController({ messenger: controllerMessenger, + queryApiClient, + subscribeToBasicFunctionalityChange: (onChange) => { + // Extension: subscribe to PreferencesController:stateChange, call onChange(useExternalServices ?? true). + // Mobile: subscribe to your app's basic-functionality setting and call onChange(isBasic). + }, state: existingState, // Optional: restore persisted state defaultUpdateInterval: 30_000, // Optional: polling hint (30s default) }); diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts new file mode 100644 index 00000000000..afc0778ff73 --- /dev/null +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -0,0 +1,253 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import * as ProviderModule from '@ethersproject/providers'; +import { + MOCK_ANY_NAMESPACE, + Messenger, + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { NetworkStatus } from '@metamask/network-controller'; + +import { + NetworkState, + RpcEndpoint, + RpcEndpointType, +} from '../../../network-controller/src/NetworkController'; +import { + AssetsControllerMessenger, + getDefaultAssetsControllerState, +} from '../AssetsController'; +import { STAKING_INTERFACE } from '../data-sources/evm-rpc-services/services/StakedBalanceFetcher'; + +// Test escape hatch for mocking areas that do not need explicit types +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TestMockType = any; + +export type MockRootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +const MAINNET_CHAIN_ID_HEX = '0x1'; +const MOCK_CHAIN_ID_CAIP = 'eip155:1'; + +export function createMockAssetControllerMessenger(): { + rootMessenger: MockRootMessenger; + assetsControllerMessenger: AssetsControllerMessenger; +} { + const rootMessenger: MockRootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const assetsControllerMessenger: AssetsControllerMessenger = new Messenger({ + namespace: 'AssetsController', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: assetsControllerMessenger, + actions: [ + // AssetsController + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + // RpcDataSource + 'TokenListController:getState', + 'NetworkController:getState', + 'NetworkController:getNetworkClientById', + // RpcDataSource, StakedBalanceDataSource + 'NetworkEnablementController:getState', + // SnapDataSource + 'SnapController:getRunnableSnaps', + 'SnapController:handleRequest', + 'PermissionController:getPermissions', + // BackendWebsocketDataSource + 'BackendWebSocketService:connect', + 'BackendWebSocketService:disconnect', + 'BackendWebSocketService:forceReconnection', + 'BackendWebSocketService:sendMessage', + 'BackendWebSocketService:sendRequest', + 'BackendWebSocketService:getConnectionInfo', + 'BackendWebSocketService:getSubscriptionsByChannel', + 'BackendWebSocketService:channelHasSubscription', + 'BackendWebSocketService:findSubscriptionsByChannelPrefix', + 'BackendWebSocketService:addChannelCallback', + 'BackendWebSocketService:removeChannelCallback', + 'BackendWebSocketService:getChannelCallbacks', + 'BackendWebSocketService:subscribe', + ], + events: [ + // AssetsController + 'AccountTreeController:selectedAccountGroupChange', + 'KeyringController:lock', + 'KeyringController:unlock', + 'PreferencesController:stateChange', + // RpcDataSource, StakedBalanceDataSource + 'NetworkController:stateChange', + 'TransactionController:transactionConfirmed', + 'TransactionController:incomingTransactionsReceived', + // StakedBalanceDataSource + 'NetworkEnablementController:stateChange', + // SnapDataSource + 'AccountsController:accountBalancesUpdated', + 'PermissionController:stateChange', + // BackendWebsocketDataSource + 'BackendWebSocketService:connectionStateChanged', + ], + }); + + return { + rootMessenger, + assetsControllerMessenger, + }; +} + +export function registerStakedMessengerActions( + rootMessenger: MockRootMessenger, + opts = { + enabledNetworkMap: { eip155: { [MAINNET_CHAIN_ID_HEX]: true } } as Record< + string, + Record + >, + mockProvider: createMockWeb3Provider({ + sharesWei: '1000000000000000000', + assetsWei: '1500000000000000000', + }), + }, +): void { + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => ({ + enabledNetworkMap: opts.enabledNetworkMap, + nativeAssetIdentifiers: {}, + }), + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => + ({ + provider: opts.mockProvider, + configuration: { chainId: MAINNET_CHAIN_ID_HEX }, + }) as TestMockType, + ); + + rootMessenger.registerActionHandler('NetworkController:getState', () => ({ + networkConfigurationsByChainId: { + [MAINNET_CHAIN_ID_HEX]: { + chainId: MAINNET_CHAIN_ID_HEX, + rpcEndpoints: [{ networkClientId: 'mainnet' }] as RpcEndpoint[], + defaultRpcEndpointIndex: 0, + blockExplorerUrls: [], + name: 'Mainnet', + nativeCurrency: 'ETH', + }, + }, + networksMetadata: {}, + selectedNetworkClientId: 'mainnet', + })); +} + +export function registerRpcDataSourceActions( + rootMessenger: MockRootMessenger, + opts?: { + networkState?: NetworkState; + }, +): void { + rootMessenger.registerActionHandler( + 'NetworkController:getState', + () => opts?.networkState ?? createMockNetworkState(), + ); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + () => + ({ + provider: { request: jest.fn().mockResolvedValue('0x0') }, + configuration: { chainId: MAINNET_CHAIN_ID_HEX }, + }) as TestMockType, + ); + + rootMessenger.registerActionHandler('AssetsController:getState', () => + getDefaultAssetsControllerState(), + ); + + rootMessenger.registerActionHandler('TokenListController:getState', () => ({ + tokensChainsCache: {}, + })); + + rootMessenger.registerActionHandler( + 'NetworkEnablementController:getState', + () => ({ + enabledNetworkMap: {}, + nativeAssetIdentifiers: { + [MOCK_CHAIN_ID_CAIP]: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, + }, + }), + ); +} + +export function createMockWeb3Provider( + options = { + sharesWei: '1000000000000000000', + assetsWei: '1500000000000000000', + }, +): jest.SpyInstance { + const mockProvider = jest.spyOn(ProviderModule, 'Web3Provider'); + + const mockCalls = jest.fn().mockImplementation((callData) => { + // Will decode and return mock shares or throw + try { + STAKING_INTERFACE.decodeFunctionData('getShares', callData.data); + return defaultAbiCoder.encode(['uint256'], [options.sharesWei]); + } catch { + // do nothing + } + + // Will decode and return mock assets or throw + try { + STAKING_INTERFACE.decodeFunctionData('convertToAssets', callData.data); + return defaultAbiCoder.encode(['uint256'], [options.assetsWei]); + } catch { + // do nothing + } + + throw new Error('MOCK FAILURE: Invalid function data'); + }); + + mockProvider.mockReturnValue({ + call: mockCalls, + } as unknown as ProviderModule.Web3Provider); + + return mockProvider; +} + +export function createMockNetworkState( + chainStatus: NetworkStatus = NetworkStatus.Available, +): NetworkState { + return { + selectedNetworkClientId: 'mainnet', + networkConfigurationsByChainId: { + [MAINNET_CHAIN_ID_HEX]: { + chainId: MAINNET_CHAIN_ID_HEX, + name: 'Mainnet', + nativeCurrency: 'ETH', + defaultRpcEndpointIndex: 0, + rpcEndpoints: [ + { + networkClientId: 'mainnet', + url: 'https://mainnet.infura.io', + type: RpcEndpointType.Custom, + }, + ], + blockExplorerUrls: [], + }, + }, + networksMetadata: { + mainnet: { + status: chainStatus, + EIPS: {}, + }, + }, + } as unknown as NetworkState; +} diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 586c4c5082a..72cdb039cba 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -8,8 +8,16 @@ import type { AccountsApiDataSourceOptions, AccountsApiDataSourceAllowedActions, } from './AccountsApiDataSource'; -import { AccountsApiDataSource } from './AccountsApiDataSource'; -import type { ChainId, DataRequest, Context } from '../types'; +import { + AccountsApiDataSource, + filterResponseToKnownAssets, +} from './AccountsApiDataSource'; +import type { + ChainId, + DataRequest, + Context, + AssetsControllerStateInternal, +} from '../types'; type AllActions = AccountsApiDataSourceAllowedActions; type AllEvents = never; @@ -434,4 +442,456 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); + + describe('tokenDetectionEnabled', () => { + async function setupControllerWithDetection( + options: { + supportedChains?: number[]; + balances?: V5BalanceItem[]; + unprocessedNetworks?: string[]; + tokenDetectionEnabled?: boolean; + } = {}, + ): Promise { + const { + supportedChains = [1, 137], + balances = [], + unprocessedNetworks = [], + tokenDetectionEnabled, + } = options; + + const rootMessenger = new Messenger< + MockAnyNamespace, + AllActions, + AllEvents + >({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const controllerMessenger = new Messenger< + 'AccountsApiDataSource', + AllActions, + AllEvents, + RootMessenger + >({ + namespace: 'AccountsApiDataSource', + parent: rootMessenger, + }); + + rootMessenger.delegate({ + messenger: controllerMessenger, + actions: [], + events: [], + }); + + const assetsUpdateHandler = jest.fn().mockResolvedValue(undefined); + const activeChainsUpdateHandler = jest.fn(); + + const apiClient = createMockApiClient( + supportedChains, + balances, + unprocessedNetworks, + ); + + const controllerOptions: AccountsApiDataSourceOptions = { + queryApiClient: + apiClient as unknown as AccountsApiDataSourceOptions['queryApiClient'], + onActiveChainsUpdated: (dataSourceName, chains, previousChains): void => + activeChainsUpdateHandler(dataSourceName, chains, previousChains), + }; + + if (tokenDetectionEnabled !== undefined) { + controllerOptions.tokenDetectionEnabled = (): boolean => + tokenDetectionEnabled; + } + + const controller = new AccountsApiDataSource(controllerOptions); + + // Wait for async initialization + await new Promise(process.nextTick); + + return { + controller, + messenger: rootMessenger, + apiClient, + assetsUpdateHandler, + activeChainsUpdateHandler, + }; + } + + const KNOWN_ASSET = + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const UNKNOWN_ASSET = + 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const ACCOUNT_ID = 'mock-account-id'; + + it('includes all tokens when tokenDetectionEnabled is true (default)', async () => { + const { controller, assetsUpdateHandler } = + await setupControllerWithDetection({ + balances: [ + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + KNOWN_ASSET, + '1000', + ), + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + UNKNOWN_ASSET, + '2000', + ), + ], + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: assetsUpdateHandler, + getAssetsState: () => ({ + assetsInfo: {}, + assetsBalance: { + [ACCOUNT_ID]: { + [KNOWN_ASSET]: { amount: '500' }, + }, + }, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }), + }); + + expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); + const response = assetsUpdateHandler.mock.calls[0][0]; + // Both tokens should be included + expect( + Object.keys(response.assetsBalance?.[ACCOUNT_ID] ?? {}), + ).toHaveLength(2); + + controller.destroy(); + }); + + it('filters out unknown tokens when tokenDetectionEnabled is false', async () => { + const { controller, assetsUpdateHandler } = + await setupControllerWithDetection({ + tokenDetectionEnabled: false, + balances: [ + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + KNOWN_ASSET, + '1000', + ), + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + UNKNOWN_ASSET, + '2000', + ), + ], + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: assetsUpdateHandler, + getAssetsState: () => ({ + assetsInfo: {}, + assetsBalance: { + [ACCOUNT_ID]: { + [KNOWN_ASSET]: { amount: '500' }, + }, + }, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }), + }); + + expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); + const response = assetsUpdateHandler.mock.calls[0][0]; + // Only the known token should be included + const accountBalances = response.assetsBalance?.[ACCOUNT_ID] ?? {}; + expect(Object.keys(accountBalances)).toHaveLength(1); + expect(accountBalances[KNOWN_ASSET]).toStrictEqual({ amount: '1000' }); + expect(accountBalances[UNKNOWN_ASSET]).toBeUndefined(); + + controller.destroy(); + }); + + it('returns empty balance when no tokens are known and tokenDetectionEnabled is false', async () => { + const { controller, assetsUpdateHandler } = + await setupControllerWithDetection({ + tokenDetectionEnabled: false, + balances: [ + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + UNKNOWN_ASSET, + '2000', + ), + ], + }); + + await controller.subscribe({ + subscriptionId: 'sub-1', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: assetsUpdateHandler, + getAssetsState: () => ({ + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }), + }); + + expect(assetsUpdateHandler).toHaveBeenCalledTimes(1); + const response = assetsUpdateHandler.mock.calls[0][0]; + // No balances should be returned + expect(response.assetsBalance).toBeUndefined(); + + controller.destroy(); + }); + + it('filters unknown tokens in middleware when tokenDetectionEnabled is false', async () => { + const { controller } = await setupControllerWithDetection({ + tokenDetectionEnabled: false, + balances: [ + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + KNOWN_ASSET, + '1000', + ), + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + UNKNOWN_ASSET, + '2000', + ), + ], + }); + + // Set up state accessor via subscribe (middleware uses the stored getAssetsState) + await controller.subscribe({ + subscriptionId: 'sub-setup', + request: createDataRequest(), + isUpdate: false, + onAssetsUpdate: jest.fn(), + getAssetsState: () => ({ + assetsInfo: {}, + assetsBalance: { + [ACCOUNT_ID]: { + [KNOWN_ASSET]: { amount: '500' }, + }, + }, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }), + }); + + const middleware = controller.assetsMiddleware; + const context = createMiddlewareContext(); + const nextFn = jest.fn(); + + await middleware(context, nextFn); + + // Verify only known asset is in the response + const accountBalances = + context.response.assetsBalance?.[ACCOUNT_ID] ?? {}; + expect(accountBalances[KNOWN_ASSET as never]).toStrictEqual({ + amount: '1000', + }); + expect(accountBalances[UNKNOWN_ASSET as never]).toBeUndefined(); + + controller.destroy(); + }); + + it('middleware does not remove chains when tokenDetectionEnabled is false and filter removes all balance data (bootstrap for RPC)', async () => { + const { controller } = await setupControllerWithDetection({ + tokenDetectionEnabled: false, + supportedChains: [1, 137], + balances: [ + createMockBalanceItem( + `eip155:1:${MOCK_ADDRESS}`, + 'eip155:1/slip44:60', + '1000000000000000000', + ), + ], + }); + + // Simulate new account: subscribe with empty state so #getAssetsState is set. + // Filter will then remove all API balance data (no assets in state). + await controller.subscribe({ + subscriptionId: 'sub-setup', + request: createDataRequest({ + chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], + }), + isUpdate: false, + onAssetsUpdate: jest.fn(), + getAssetsState: () => ({ + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }), + }); + + const nextFn = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext({ + request: createDataRequest({ + chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], + }), + }); + + await controller.assetsMiddleware(context, nextFn); + + // All chains must still be passed to next middleware so RPC can fetch native balances + expect(nextFn).toHaveBeenCalledWith( + expect.objectContaining({ + request: expect.objectContaining({ + chainIds: [CHAIN_MAINNET, CHAIN_POLYGON], + }), + }), + ); + + controller.destroy(); + }); + }); +}); + +// ============================================================================= +// filterResponseToKnownAssets — standalone unit tests +// ============================================================================= + +describe('filterResponseToKnownAssets', () => { + const ACCOUNT_A = 'account-a'; + const ACCOUNT_B = 'account-b'; + const ASSET_1 = 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const ASSET_2 = 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7'; + const ASSET_3 = 'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F'; + + function buildState( + balances: Record>, + ): AssetsControllerStateInternal { + return { + assetsInfo: {}, + assetsBalance: balances, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }; + } + + it('returns response unchanged when assetsBalance is undefined', () => { + const response = { errors: { 'eip155:1': 'fail' } }; + const state = buildState({}); + + expect(filterResponseToKnownAssets(response, state)).toStrictEqual( + response, + ); + }); + + it('keeps only assets that exist in state', () => { + const response = { + assetsBalance: { + [ACCOUNT_A]: { + [ASSET_1]: { amount: '100' }, + [ASSET_2]: { amount: '200' }, + }, + }, + }; + const state = buildState({ + [ACCOUNT_A]: { [ASSET_1]: { amount: '50' } }, + }); + + const result = filterResponseToKnownAssets(response, state); + + expect(result.assetsBalance?.[ACCOUNT_A]).toStrictEqual({ + [ASSET_1]: { amount: '100' }, + }); + expect( + result.assetsBalance?.[ACCOUNT_A]?.[ASSET_2 as never], + ).toBeUndefined(); + }); + + it('drops accounts that have no balances in state', () => { + const response = { + assetsBalance: { + [ACCOUNT_A]: { [ASSET_1]: { amount: '100' } }, + [ACCOUNT_B]: { [ASSET_2]: { amount: '200' } }, + }, + }; + const state = buildState({ + [ACCOUNT_A]: { [ASSET_1]: { amount: '10' } }, + // ACCOUNT_B not in state + }); + + const result = filterResponseToKnownAssets(response, state); + + expect(result.assetsBalance?.[ACCOUNT_A]).toBeDefined(); + expect(result.assetsBalance?.[ACCOUNT_B]).toBeUndefined(); + }); + + it('returns undefined assetsBalance when all assets are filtered out', () => { + const response = { + assetsBalance: { + [ACCOUNT_A]: { [ASSET_1]: { amount: '100' } }, + }, + }; + const state = buildState({ + [ACCOUNT_A]: { [ASSET_3]: { amount: '10' } }, + }); + + const result = filterResponseToKnownAssets(response, state); + + expect(result.assetsBalance).toBeUndefined(); + }); + + it('preserves other response fields (errors, etc.)', () => { + const response = { + assetsBalance: { + [ACCOUNT_A]: { [ASSET_1]: { amount: '100' } }, + }, + errors: { 'eip155:137': 'Unprocessed by Accounts API' }, + }; + const state = buildState({ + [ACCOUNT_A]: { [ASSET_1]: { amount: '50' } }, + }); + + const result = filterResponseToKnownAssets(response, state); + + expect(result.errors).toStrictEqual({ + 'eip155:137': 'Unprocessed by Accounts API', + }); + expect(result.assetsBalance?.[ACCOUNT_A]).toStrictEqual({ + [ASSET_1]: { amount: '100' }, + }); + }); + + it('handles multiple accounts with mixed known/unknown assets', () => { + const response = { + assetsBalance: { + [ACCOUNT_A]: { + [ASSET_1]: { amount: '100' }, + [ASSET_2]: { amount: '200' }, + }, + [ACCOUNT_B]: { + [ASSET_2]: { amount: '300' }, + [ASSET_3]: { amount: '400' }, + }, + }, + }; + const state = buildState({ + [ACCOUNT_A]: { [ASSET_2]: { amount: '10' } }, + [ACCOUNT_B]: { [ASSET_3]: { amount: '20' } }, + }); + + const result = filterResponseToKnownAssets(response, state); + + expect(result.assetsBalance?.[ACCOUNT_A]).toStrictEqual({ + [ASSET_2]: { amount: '200' }, + }); + expect(result.assetsBalance?.[ACCOUNT_B]).toStrictEqual({ + [ASSET_3]: { amount: '400' }, + }); + }); }); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index d307223c80d..a0846e390eb 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -1,5 +1,10 @@ import type { V5BalanceItem } from '@metamask/core-backend'; import { ApiPlatformClient } from '@metamask/core-backend'; +import { + isCaipChainId, + KnownCaipNamespace, + toCaipChainId, +} from '@metamask/utils'; import type { DataSourceState, @@ -14,6 +19,7 @@ import type { DataRequest, DataResponse, Middleware, + AssetsControllerStateInternal, } from '../types'; import { normalizeAssetId } from '../utils'; @@ -48,7 +54,19 @@ const defaultState: AccountsApiDataSourceState = { // OPTIONS // ============================================================================ -export type AccountsApiDataSourceOptions = { +/** Optional configuration for AccountsApiDataSource. */ +export type AccountsApiDataSourceConfig = { + /** Polling interval in ms (default: 30000) */ + pollInterval?: number; + /** + * Function returning whether token detection is enabled (default: () => true). + * When it returns false, balances are only returned for tokens already in state. + * Using a getter avoids stale values when the user toggles the preference at runtime. + */ + tokenDetectionEnabled?: () => boolean; +}; + +export type AccountsApiDataSourceOptions = AccountsApiDataSourceConfig & { /** ApiPlatformClient for API calls with caching */ queryApiClient: ApiPlatformClient; /** Called when active chains are updated. Pass dataSourceName so the controller knows the source. */ @@ -57,7 +75,6 @@ export type AccountsApiDataSourceOptions = { chains: ChainId[], previousChains: ChainId[], ) => void; - pollInterval?: number; state?: Partial; }; @@ -68,30 +85,77 @@ export type AccountsApiDataSourceOptions = { function decimalToChainId(decimalChainId: number | string): ChainId { // Handle both decimal numbers and already-formatted CAIP chain IDs if (typeof decimalChainId === 'string') { - // If already a CAIP chain ID (e.g., "eip155:1"), return as-is - if (decimalChainId.startsWith('eip155:')) { - return decimalChainId as ChainId; + if (isCaipChainId(decimalChainId)) { + return decimalChainId; } - // If it's a string number, convert - return `eip155:${decimalChainId}` as ChainId; + return toCaipChainId(KnownCaipNamespace.Eip155, decimalChainId); } - return `eip155:${decimalChainId}` as ChainId; + return toCaipChainId(KnownCaipNamespace.Eip155, String(decimalChainId)); } /** * Convert a CAIP-2 chain ID from the API response to our ChainId type. * Handles both formats: "eip155:1" or just "1" (decimal). + * Uses @metamask/utils for CAIP parsing. * * @param chainIdStr - The chain ID string to convert. * @returns The normalized ChainId. */ function caipChainIdToChainId(chainIdStr: string): ChainId { - // If already in CAIP-2 format, return as-is - if (chainIdStr.includes(':')) { - return chainIdStr as ChainId; + if (isCaipChainId(chainIdStr)) { + return chainIdStr; + } + return toCaipChainId(KnownCaipNamespace.Eip155, chainIdStr); +} + +/** + * Filter a response to only include balances for assets already in state. + * Used when tokenDetectionEnabled is false to prevent adding new tokens. + * + * @param response - The fetch response to filter. + * @param assetsState - Current assets controller state to check existing balances against. + * @returns A new response with only known asset balances. + */ +export function filterResponseToKnownAssets( + response: DataResponse, + assetsState: AssetsControllerStateInternal, +): DataResponse { + if (!response.assetsBalance) { + return response; + } + + const filteredBalance: Record< + string, + Record + > = {}; + + for (const [accountId, accountBalances] of Object.entries( + response.assetsBalance, + )) { + const existingBalances = assetsState.assetsBalance[accountId]; + if (!existingBalances) { + // Account has no balances in state yet — skip all its tokens + continue; + } + + const filtered: Record = {}; + for (const [assetId, balance] of Object.entries(accountBalances)) { + // Only include assets already tracked in state + if (assetId in existingBalances) { + filtered[assetId as Caip19AssetId] = balance; + } + } + + if (Object.keys(filtered).length > 0) { + filteredBalance[accountId] = filtered; + } } - // If decimal number, convert to CAIP-2 - return `eip155:${chainIdStr}` as ChainId; + + return { + ...response, + assetsBalance: + Object.keys(filteredBalance).length > 0 ? filteredBalance : undefined, + }; } // ============================================================================ @@ -116,12 +180,18 @@ export class AccountsApiDataSource extends AbstractDataSource< readonly #pollInterval: number; + /** Getter avoids stale value when user toggles token detection at runtime. */ + readonly #tokenDetectionEnabled: () => boolean; + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; /** Chains refresh timer */ #chainsRefreshTimer: ReturnType | null = null; + /** State accessor from subscriptions (for filtering when tokenDetectionEnabled is false) */ + #getAssetsState?: () => AssetsControllerStateInternal; + constructor(options: AccountsApiDataSourceOptions) { super(CONTROLLER_NAME, { ...defaultState, @@ -130,6 +200,8 @@ export class AccountsApiDataSource extends AbstractDataSource< this.#onActiveChainsUpdated = options.onActiveChainsUpdated; this.#pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL; + this.#tokenDetectionEnabled = + options.tokenDetectionEnabled ?? ((): boolean => true); this.#apiClient = options.queryApiClient; this.#initializeActiveChains().catch(console.error); @@ -198,7 +270,7 @@ export class AccountsApiDataSource extends AbstractDataSource< // ============================================================================ async fetch(request: DataRequest): Promise { - const response: DataResponse = {}; + let response: DataResponse = {}; // Filter to only chains supported by Accounts API const supportedChains = new Set(this.state.activeChains); @@ -272,6 +344,11 @@ export class AccountsApiDataSource extends AbstractDataSource< } } + // When token detection is disabled, filter out tokens not already in state + if (!this.#tokenDetectionEnabled() && this.#getAssetsState) { + response = filterResponseToKnownAssets(response, this.#getAssetsState()); + } + return response; } @@ -380,6 +457,17 @@ export class AccountsApiDataSource extends AbstractDataSource< successfullyHandledChains = request.chainIds.filter( (chainId) => !unprocessedChains.has(chainId), ); + + // When token detection is off and we filtered out all balance data (e.g. new + // account with empty state), do not claim any chain as handled so that RPC + // middleware can still process them and fetch native balances (ETH, MATIC, etc.). + if ( + !this.#tokenDetectionEnabled() && + (!response.assetsBalance || + Object.keys(response.assetsBalance).length === 0) + ) { + successfullyHandledChains = []; + } } catch (error) { log('Middleware fetch failed', { error }); successfullyHandledChains = []; @@ -412,6 +500,11 @@ export class AccountsApiDataSource extends AbstractDataSource< async subscribe(subscriptionRequest: SubscriptionRequest): Promise { const { request, subscriptionId, isUpdate } = subscriptionRequest; + // Store state accessor for filtering when tokenDetectionEnabled is false + if (subscriptionRequest.getAssetsState) { + this.#getAssetsState = subscriptionRequest.getAssetsState; + } + // Try all requested chains - API will handle unsupported ones via unprocessedNetworks const chainsToSubscribe = request.chainIds; diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts index 186636622e9..02684639727 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.test.ts @@ -600,7 +600,7 @@ describe('BackendWebsocketDataSource', () => { 'eip155:8453/slip44:60': { amount: '10000000000000000000' }, }), }), - assetsMetadata: expect.objectContaining({ + assetsInfo: expect.objectContaining({ 'eip155:8453/slip44:60': expect.objectContaining({ type: 'native', symbol: 'ETH', @@ -668,7 +668,7 @@ describe('BackendWebsocketDataSource', () => { }, }), }), - assetsMetadata: expect.objectContaining({ + assetsInfo: expect.objectContaining({ 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': expect.objectContaining({ type: 'erc20', diff --git a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts index 47bc2e25c99..6d38ac55bb7 100644 --- a/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts +++ b/packages/assets-controller/src/data-sources/BackendWebsocketDataSource.ts @@ -8,6 +8,11 @@ import type { BalanceUpdate, } from '@metamask/core-backend'; import type { ApiPlatformClient } from '@metamask/core-backend'; +import { + isCaipChainId, + KnownCaipNamespace, + toCaipChainId, +} from '@metamask/utils'; import { AbstractDataSource } from './AbstractDataSource'; import type { @@ -153,20 +158,21 @@ function buildAccountActivityChannel( /** * Normalize API chain identifier to CAIP-2 ChainId. - * Passes through strings already in namespace:reference form (e.g. eip155:1, solana:5eykt...). + * Passes through strings already in CAIP-2 form (e.g. eip155:1, solana:5eykt...). * Converts bare decimals to eip155:decimal. + * Uses @metamask/utils for CAIP parsing. * * @param chainIdOrDecimal - Chain ID string (CAIP-2 or decimal) or decimal number. * @returns CAIP-2 ChainId. */ function toChainId(chainIdOrDecimal: number | string): ChainId { if (typeof chainIdOrDecimal === 'string') { - if (chainIdOrDecimal.includes(':')) { - return chainIdOrDecimal as ChainId; + if (isCaipChainId(chainIdOrDecimal)) { + return chainIdOrDecimal; } - return `eip155:${chainIdOrDecimal}` as ChainId; + return toCaipChainId(KnownCaipNamespace.Eip155, chainIdOrDecimal); } - return `eip155:${chainIdOrDecimal}` as ChainId; + return toCaipChainId(KnownCaipNamespace.Eip155, String(chainIdOrDecimal)); } // Note: AccountActivityMessage and BalanceUpdate types are imported from @metamask/core-backend @@ -621,7 +627,7 @@ export class BackendWebsocketDataSource extends AbstractDataSource< const response: DataResponse = {}; if (Object.keys(assetsBalance[accountId]).length > 0) { response.assetsBalance = assetsBalance; - response.assetsMetadata = assetsMetadata; + response.assetsInfo = assetsMetadata; } return response; diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.test.ts b/packages/assets-controller/src/data-sources/PriceDataSource.test.ts index 8391a67e0f5..123c09ae3fe 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.test.ts @@ -158,7 +158,7 @@ describe('PriceDataSource', () => { it('initializes with correct name', () => { const { controller } = setupController(); - expect(controller.name).toBe('PriceDataSource'); + expect(controller.getName()).toBe('PriceDataSource'); controller.destroy(); }); @@ -219,6 +219,39 @@ describe('PriceDataSource', () => { controller.destroy(); }); + it('fetch skips malformed asset IDs in balance state and still fetches prices for valid assets', async () => { + const { controller, apiClient, getAssetsState } = setupController({ + balanceState: { + 'mock-account-id': { + 'not-a-valid-caip19': { amount: '999' }, + [MOCK_NATIVE_ASSET]: { amount: '1000000000000000000' }, + }, + }, + priceResponse: { + [MOCK_NATIVE_ASSET]: createMockPriceData(2500), + }, + }); + + const response = await controller.fetch( + createDataRequest(), + getAssetsState, + ); + + expect(apiClient.prices.fetchV3SpotPrices).toHaveBeenCalledWith( + [MOCK_NATIVE_ASSET], + { currency: 'usd', includeMarketData: true }, + ); + expect(response.assetsPrice?.[MOCK_NATIVE_ASSET]).toStrictEqual({ + price: 2500, + pricePercentChange1d: 2.5, + lastUpdated: expect.any(Number), + marketCap: 1000000000, + totalVolume: 50000000, + }); + + controller.destroy(); + }); + it('fetch uses custom currency', async () => { const { controller, apiClient, getAssetsState } = setupController({ currency: 'eur', diff --git a/packages/assets-controller/src/data-sources/PriceDataSource.ts b/packages/assets-controller/src/data-sources/PriceDataSource.ts index 47a87ac46ce..3d635acf440 100644 --- a/packages/assets-controller/src/data-sources/PriceDataSource.ts +++ b/packages/assets-controller/src/data-sources/PriceDataSource.ts @@ -3,13 +3,13 @@ import type { V3SpotPricesResponse, } from '@metamask/core-backend'; import { ApiPlatformClient } from '@metamask/core-backend'; +import { parseCaipAssetType } from '@metamask/utils'; import type { SubscriptionRequest } from './AbstractDataSource'; import { projectLogger, createModuleLogger } from '../logger'; import { forDataTypes } from '../types'; import type { Caip19AssetId, - ChainId, DataRequest, DataResponse, FungibleAssetPrice, @@ -30,13 +30,17 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME); // OPTIONS // ============================================================================ -export type PriceDataSourceOptions = { +/** Optional configuration for PriceDataSource. */ +export type PriceDataSourceConfig = { + /** Polling interval in ms (default: 60000) */ + pollInterval?: number; +}; + +export type PriceDataSourceOptions = PriceDataSourceConfig & { /** ApiPlatformClient for API calls with caching */ queryApiClient: ApiPlatformClient; /** Currency to fetch prices in (default: 'usd') */ currency?: SupportedCurrency; - /** Polling interval in ms (default: 60000) */ - pollInterval?: number; }; // ============================================================================ @@ -100,7 +104,11 @@ function isValidMarketData(data: unknown): data is SpotPriceMarketData { * Usage: Create with queryApiClient; subscribe() requires getAssetsState in the request for balance-based pricing. */ export class PriceDataSource { - readonly name = CONTROLLER_NAME; + static readonly controllerName = CONTROLLER_NAME; + + getName(): string { + return PriceDataSource.controllerName; + } readonly #currency: SupportedCurrency; @@ -255,10 +263,20 @@ export class PriceDataSource { for (const assetId of Object.keys( accountBalances as Record, )) { - // Filter by chain if specified + // Filter by chain if specified; skip malformed asset IDs for this entry only if (chainFilter) { - const chainId = assetId.split('/')[0] as ChainId; - if (!chainFilter.has(chainId)) { + try { + const { chainId } = parseCaipAssetType( + assetId as Caip19AssetId, + ); + if (!chainFilter.has(chainId)) { + continue; + } + } catch (error) { + log('Skipping malformed asset ID in balance state', { + assetId, + error, + }); continue; } } diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts index f40a21b7cf6..369725b4c07 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.test.ts @@ -1,23 +1,18 @@ /* eslint-disable jest/unbound-method */ import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; -import type { MockAnyNamespace } from '@metamask/messenger'; import type { NetworkState } from '@metamask/network-controller'; import { NetworkStatus, RpcEndpointType } from '@metamask/network-controller'; -import type { - RpcDataSourceOptions, - RpcDataSourceAllowedActions, - RpcDataSourceAllowedEvents, -} from './RpcDataSource'; -import { RpcDataSource, createRpcDataSource } from './RpcDataSource'; +import type { RpcDataSourceOptions } from './RpcDataSource'; +import { RpcDataSource } from './RpcDataSource'; +import { + createMockAssetControllerMessenger, + MockRootMessenger, + registerRpcDataSourceActions, +} from '../__fixtures__/MockAssetControllerMessenger'; import type { AssetsControllerMessenger } from '../AssetsController'; import type { ChainId, DataRequest, Context } from '../types'; -type AllActions = RpcDataSourceAllowedActions; -type AllEvents = RpcDataSourceAllowedEvents; -type RootMessenger = Messenger; - const MOCK_CHAIN_ID_HEX = '0x1'; const MOCK_CHAIN_ID_CAIP = 'eip155:1' as ChainId; const MOCK_ACCOUNT_ID = 'mock-account-id'; @@ -26,12 +21,6 @@ type EthereumProvider = { request: jest.Mock; }; -function createMockProvider(): EthereumProvider { - return { - request: jest.fn().mockResolvedValue('0x0'), - }; -} - function createMockInternalAccount( overrides?: Partial, ): InternalAccount { @@ -119,7 +108,8 @@ type WithControllerCallback = ({ onActiveChainsUpdated, }: { controller: RpcDataSource; - messenger: RootMessenger; + rootMessenger: MockRootMessenger; + messenger: AssetsControllerMessenger; onActiveChainsUpdated: ( dataSourceName: string, chains: ChainId[], @@ -140,98 +130,27 @@ async function withController( | [WithControllerCallback] ): Promise { const [controllerOptions, fn] = args.length === 2 ? args : [{}, args[0]]; - const { - options = {}, - networkState = createMockNetworkState(), - actionHandlerOverrides = {}, - } = controllerOptions; - - const messenger: RootMessenger = new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); + const { options = {}, networkState = createMockNetworkState() } = + controllerOptions; - const rpcDataSourceMessenger = new Messenger< - 'RpcDataSource', - AllActions, - AllEvents, - RootMessenger - >({ - namespace: 'RpcDataSource', - parent: messenger, - }); + const { rootMessenger, assetsControllerMessenger } = + createMockAssetControllerMessenger(); + registerRpcDataSourceActions(rootMessenger, { networkState }); - messenger.delegate({ - messenger: rpcDataSourceMessenger, - actions: [ - 'NetworkController:getState', - 'NetworkController:getNetworkClientById', - 'AssetsController:getState', - 'TokenListController:getState', - 'NetworkEnablementController:getState', - ], - events: ['NetworkController:stateChange'], - }); - - // Mock NetworkController:getState - messenger.registerActionHandler( - 'NetworkController:getState', - actionHandlerOverrides['NetworkController:getState'] ?? - ((): NetworkState => networkState), - ); - - // Mock NetworkController:getNetworkClientById - messenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - actionHandlerOverrides['NetworkController:getNetworkClientById'] ?? - ((): { - provider: EthereumProvider; - configuration: { chainId: string }; - } => ({ - provider: createMockProvider(), - configuration: { chainId: MOCK_CHAIN_ID_HEX }, - })), - ); - - // Mock AssetsController:getState - messenger.registerActionHandler('AssetsController:getState', () => ({ - assetsMetadata: {}, - assetsBalance: {}, - })); - - // Mock TokenListController:getState - messenger.registerActionHandler('TokenListController:getState', () => ({ - tokensChainsCache: {}, - })); - - // Mock NetworkEnablementController:getState - messenger.registerActionHandler( - 'NetworkEnablementController:getState', - () => ({ - enabledNetworkMap: {}, - nativeAssetIdentifiers: { - [MOCK_CHAIN_ID_CAIP]: `${MOCK_CHAIN_ID_CAIP}/slip44:60`, - }, - }), - ); - - const onActiveChainsUpdated = - ( - options as { - onActiveChainsUpdated?: ( - dataSourceName: string, - chains: ChainId[], - previousChains: ChainId[], - ) => void; - } - ).onActiveChainsUpdated ?? jest.fn(); + const onActiveChainsUpdated = options.onActiveChainsUpdated ?? jest.fn(); const controller = new RpcDataSource({ - messenger: rpcDataSourceMessenger as unknown as AssetsControllerMessenger, + messenger: assetsControllerMessenger, onActiveChainsUpdated, ...options, }); try { - return await fn({ controller, messenger, onActiveChainsUpdated }); + return await fn({ + controller, + messenger: assetsControllerMessenger, + rootMessenger, + onActiveChainsUpdated, + }); } finally { controller.destroy(); } @@ -285,7 +204,7 @@ describe('RpcDataSource', () => { it('initializes with token detection enabled', async () => { await withController( - { options: { tokenDetectionEnabled: true } }, + { options: { tokenDetectionEnabled: () => true } }, ({ controller }) => { expect(controller).toBeDefined(); }, @@ -329,24 +248,28 @@ describe('RpcDataSource', () => { }, }, }, - async ({ controller, messenger }) => { + async ({ controller, rootMessenger }) => { source = controller; // Trigger callback via network state change (first call is during construction, before source is set). const newNetworkState = createMockNetworkState( NetworkStatus.Available, ); - (messenger.publish as CallableFunction)( + rootMessenger.publish( 'NetworkController:stateChange', newNetworkState, [], ); await new Promise(process.nextTick); expect(callbackResult).not.toBeNull(); - const result = callbackResult as { - syncChains: ChainId[]; - newChains: ChainId[]; + const assertNotNull: ( + value: Val | null, + ) => asserts value is Val = (value) => { + expect(value).not.toBeNull(); }; - expect(result.syncChains).toStrictEqual(result.newChains); + assertNotNull(callbackResult); + expect(callbackResult.syncChains).toStrictEqual( + callbackResult.newChains, + ); const chains = await controller.getActiveChains(); expect(chains).toContain(MOCK_CHAIN_ID_CAIP); }, @@ -375,7 +298,7 @@ describe('RpcDataSource', () => { selectedNetworkClientId: 'mainnet', networkConfigurationsByChainId: {}, networksMetadata: {}, - } as unknown as NetworkState; + }; await withController( { networkState: emptyNetworkState }, @@ -408,7 +331,7 @@ describe('RpcDataSource', () => { it('returns undefined for non-existent chain', async () => { await withController(({ controller }) => { - const status = controller.getChainStatus('eip155:999' as ChainId); + const status = controller.getChainStatus('eip155:999'); expect(status).toBeUndefined(); }); }); @@ -438,10 +361,10 @@ describe('RpcDataSource', () => { accountsWithSupportedChains: [ { account, - supportedChains: ['eip155:999' as ChainId], + supportedChains: ['eip155:999'], }, ], - chainIds: ['eip155:999' as ChainId], + chainIds: ['eip155:999'], dataTypes: ['balance'], }; @@ -551,7 +474,7 @@ describe('RpcDataSource', () => { const middleware = controller.assetsMiddleware; const context: Context = { request: createDataRequest({ - chainIds: ['eip155:999' as ChainId], + chainIds: ['eip155:999'], }), response: {}, getAssetsState: jest.fn(), @@ -597,7 +520,7 @@ describe('RpcDataSource', () => { describe('network state changes', () => { it('updates chains when network state changes', async () => { - await withController(async ({ controller, messenger }) => { + await withController(async ({ controller, rootMessenger }) => { const newNetworkState = createMockNetworkState(NetworkStatus.Available); newNetworkState.networkConfigurationsByChainId['0x89'] = { chainId: '0x89', @@ -618,7 +541,7 @@ describe('RpcDataSource', () => { EIPS: {}, }; - (messenger.publish as CallableFunction)( + rootMessenger.publish( 'NetworkController:stateChange', newNetworkState, [], @@ -632,57 +555,6 @@ describe('RpcDataSource', () => { }); }); - describe('createRpcDataSource', () => { - it('creates an RpcDataSource instance', async () => { - const messenger: RootMessenger = new Messenger({ - namespace: MOCK_ANY_NAMESPACE, - }); - - const rpcDataSourceMessenger = new Messenger< - 'RpcDataSource', - AllActions, - AllEvents, - RootMessenger - >({ - namespace: 'RpcDataSource', - parent: messenger, - }); - - messenger.delegate({ - messenger: rpcDataSourceMessenger, - actions: [ - 'NetworkController:getState', - 'NetworkController:getNetworkClientById', - ], - events: ['NetworkController:stateChange'], - }); - - messenger.registerActionHandler('NetworkController:getState', () => - createMockNetworkState(), - ); - messenger.registerActionHandler( - 'NetworkController:getNetworkClientById', - () => ({ - provider: createMockProvider(), - configuration: { chainId: MOCK_CHAIN_ID_HEX }, - }), - ); - - const controller = createRpcDataSource({ - messenger: - rpcDataSourceMessenger as unknown as AssetsControllerMessenger, - onActiveChainsUpdated: jest.fn(), - }); - - try { - expect(controller).toBeInstanceOf(RpcDataSource); - expect(controller.getName()).toBe('RpcDataSource'); - } finally { - controller.destroy(); - } - }); - }); - describe('instance methods', () => { it('exposes getAssetsMiddleware on instance', async () => { await withController(({ controller }) => { diff --git a/packages/assets-controller/src/data-sources/RpcDataSource.ts b/packages/assets-controller/src/data-sources/RpcDataSource.ts index a509b33eb44..922f0bb40d8 100644 --- a/packages/assets-controller/src/data-sources/RpcDataSource.ts +++ b/packages/assets-controller/src/data-sources/RpcDataSource.ts @@ -1,10 +1,25 @@ import { Web3Provider } from '@ethersproject/providers'; +import type { GetTokenListState } from '@metamask/assets-controllers'; import { toHex } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import type { NetworkState, NetworkStatus } from '@metamask/network-controller'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkControllerStateChangeEvent, + NetworkState, + NetworkStatus, +} from '@metamask/network-controller'; +import type { NetworkEnablementControllerGetStateAction } from '@metamask/network-enablement-controller'; +import type { + TransactionControllerIncomingTransactionsReceivedEvent, + TransactionControllerTransactionConfirmedEvent, + TransactionMeta, +} from '@metamask/transaction-controller'; import { isStrictHexString, isCaipChainId, + numberToHex, + parseCaipAssetType, parseCaipChainId, } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -31,7 +46,10 @@ import type { BalanceFetchResult, TokenDetectionResult, } from './evm-rpc-services'; -import type { AssetsControllerMessenger } from '../AssetsController'; +import type { + AssetsControllerGetStateAction, + AssetsControllerMessenger, +} from '../AssetsController'; import { projectLogger, createModuleLogger } from '../logger'; import type { ChainId, @@ -49,83 +67,19 @@ const DEFAULT_DETECTION_INTERVAL = 180_000; // 3 minutes const log = createModuleLogger(projectLogger, CONTROLLER_NAME); -// NetworkController action to get state -export type NetworkControllerGetStateAction = { - type: 'NetworkController:getState'; - handler: () => NetworkState; -}; - -// NetworkController action to get network client by ID -export type NetworkControllerGetNetworkClientByIdAction = { - type: 'NetworkController:getNetworkClientById'; - handler: (networkClientId: string) => NetworkClient; -}; - -// Network client returned by NetworkController -export type NetworkClient = { - provider: EthereumProvider; - configuration: { - chainId: string; - }; -}; - -// Ethereum provider interface -export type EthereumProvider = { - request: (args: { method: string; params?: unknown[] }) => Promise; -}; - -// NetworkController state change event -export type NetworkControllerStateChangeEvent = { - type: 'NetworkController:stateChange'; - payload: [NetworkState, Patch[]]; -}; - -// Patch type for state changes -type Patch = { - op: 'add' | 'remove' | 'replace'; - path: string[]; - value?: unknown; -}; - -// TokenListController:getState action -type TokenListControllerGetStateAction = { - type: 'TokenListController:getState'; - handler: () => { - tokensChainsCache: Record< - string, - { timestamp: number; data: Record } - >; - }; -}; - -// AssetsController:getState action (for assets balance and metadata) -type AssetsControllerGetStateAction = { - type: 'AssetsController:getState'; - handler: () => { - assetsMetadata: Record; - assetsBalance: Record>; - }; -}; - -// NetworkEnablementController:getState action -type NetworkEnablementControllerGetStateAction = { - type: 'NetworkEnablementController:getState'; - handler: () => { - enabledNetworkMap: Record>; - nativeAssetIdentifiers: Record; - }; -}; - // Allowed actions that RpcDataSource can call export type RpcDataSourceAllowedActions = | NetworkControllerGetStateAction | NetworkControllerGetNetworkClientByIdAction | AssetsControllerGetStateAction - | TokenListControllerGetStateAction + | GetTokenListState | NetworkEnablementControllerGetStateAction; // Allowed events that RpcDataSource can subscribe to -export type RpcDataSourceAllowedEvents = NetworkControllerStateChangeEvent; +export type RpcDataSourceAllowedEvents = + | NetworkControllerStateChangeEvent + | TransactionControllerTransactionConfirmedEvent + | TransactionControllerIncomingTransactionsReceivedEvent; /** Network status for each chain */ export type ChainStatus = { @@ -144,7 +98,10 @@ export type RpcDataSourceState = Record; export type RpcDataSourceConfig = { balanceInterval?: number; detectionInterval?: number; - tokenDetectionEnabled?: boolean; + /** Function returning whether token detection is enabled (avoids stale value) */ + tokenDetectionEnabled?: () => boolean; + /** Function returning whether external services are allowed (avoids stale value; default: () => true) */ + useExternalService?: () => boolean; timeout?: number; }; @@ -163,8 +120,10 @@ export type RpcDataSourceOptions = { balanceInterval?: number; /** Token detection polling interval in ms (default: 180s / 3 min) */ detectionInterval?: number; - /** Whether token detection is enabled */ - tokenDetectionEnabled?: boolean; + /** Function returning whether token detection is enabled (avoids stale value) */ + tokenDetectionEnabled?: () => boolean; + /** Function returning whether external services are allowed (avoids stale value; default: () => true) */ + useExternalService?: () => boolean; }; /** @@ -233,7 +192,9 @@ export class RpcDataSource extends AbstractDataSource< readonly #timeout: number; - readonly #tokenDetectionEnabled: boolean; + readonly #tokenDetectionEnabled: () => boolean; + + readonly #useExternalService: () => boolean; /** Currently active chains */ #activeChains: ChainId[] = []; @@ -247,6 +208,10 @@ export class RpcDataSource extends AbstractDataSource< /** Active subscriptions by ID */ readonly #activeSubscriptions: Map = new Map(); + #unsubscribeTransactionConfirmed: (() => void) | undefined = undefined; + + #unsubscribeIncomingTransactions: (() => void) | undefined = undefined; + // Rpc-datasource components readonly #multicallClient: MulticallClient; @@ -259,7 +224,10 @@ export class RpcDataSource extends AbstractDataSource< this.#messenger = options.messenger; this.#onActiveChainsUpdated = options.onActiveChainsUpdated; this.#timeout = options.timeout ?? 10_000; - this.#tokenDetectionEnabled = options.tokenDetectionEnabled ?? false; + this.#tokenDetectionEnabled = + options.tokenDetectionEnabled ?? ((): boolean => true); + this.#useExternalService = + options.useExternalService ?? ((): boolean => true); const balanceInterval = options.balanceInterval ?? DEFAULT_BALANCE_INTERVAL; const detectionInterval = @@ -269,7 +237,8 @@ export class RpcDataSource extends AbstractDataSource< timeout: this.#timeout, balanceInterval, detectionInterval, - tokenDetectionEnabled: this.#tokenDetectionEnabled, + tokenDetectionEnabled: this.#tokenDetectionEnabled(), + useExternalService: this.#useExternalService(), }); // Initialize MulticallClient with a provider getter @@ -296,9 +265,7 @@ export class RpcDataSource extends AbstractDataSource< const tokenDetectorMessenger = { call: (_action: 'TokenListController:getState'): TokenListState => { - return ( - this.#messenger as unknown as { call: (a: string) => TokenListState } - ).call('TokenListController:getState'); + return this.#messenger.call('TokenListController:getState'); }, }; @@ -316,13 +283,18 @@ export class RpcDataSource extends AbstractDataSource< this.#tokenDetector = new TokenDetector( this.#multicallClient, tokenDetectorMessenger, - { pollingInterval: detectionInterval }, + { + pollingInterval: detectionInterval, + tokenDetectionEnabled: this.#tokenDetectionEnabled, + useExternalService: this.#useExternalService, + }, ); this.#tokenDetector.setOnDetectionUpdate( this.#handleDetectionUpdate.bind(this), ); this.#subscribeToNetworkController(); + this.#subscribeToTransactionEvents(); this.#initializeFromNetworkController(); } @@ -352,7 +324,7 @@ export class RpcDataSource extends AbstractDataSource< balances: { assetId: Caip19AssetId }[], chainId: ChainId, ): Record { - const assetsMetadata: Record = {}; + const assetsInfo: Record = {}; const existingMetadata = this.#getExistingAssetsMetadata(); for (const balance of balances) { @@ -361,7 +333,7 @@ export class RpcDataSource extends AbstractDataSource< const chainStatus = this.#chainStatuses[chainId]; if (chainStatus) { - assetsMetadata[balance.assetId] = { + assetsInfo[balance.assetId] = { type: 'native', symbol: chainStatus.nativeCurrency, name: chainStatus.nativeCurrency, @@ -373,19 +345,19 @@ export class RpcDataSource extends AbstractDataSource< const existingMeta = existingMetadata[balance.assetId]; if (existingMeta) { - assetsMetadata[balance.assetId] = existingMeta; + assetsInfo[balance.assetId] = existingMeta; } else { // Fallback to token list if not in state const tokenListMeta = this.#getTokenMetadataFromTokenList( balance.assetId, ); if (tokenListMeta) { - assetsMetadata[balance.assetId] = tokenListMeta; + assetsInfo[balance.assetId] = tokenListMeta; } else { // Default metadata for unknown ERC20 tokens. // Use 18 decimals (the standard for most ERC20 tokens) // to ensure consistent human-readable balance format. - assetsMetadata[balance.assetId] = { + assetsInfo[balance.assetId] = { type: 'erc20', symbol: '', name: '', @@ -396,7 +368,7 @@ export class RpcDataSource extends AbstractDataSource< } } - return assetsMetadata; + return assetsInfo; } /** @@ -412,14 +384,14 @@ export class RpcDataSource extends AbstractDataSource< const caipChainId = `eip155:${chainIdDecimal}` as ChainId; // Collect metadata for all balances - const assetsMetadata = this.#collectMetadataForBalances( + const assetsInfo = this.#collectMetadataForBalances( result.balances, caipChainId, ); // Convert balances to human-readable format using metadata for (const balance of result.balances) { - const metadata = assetsMetadata[balance.assetId]; + const metadata = assetsInfo[balance.assetId]; // Default to 18 decimals (ERC20 standard) for consistent human-readable format const decimals = metadata?.decimals ?? 18; const humanReadableAmount = this.#convertToHumanReadable( @@ -438,7 +410,7 @@ export class RpcDataSource extends AbstractDataSource< assetsBalance: { [result.accountId]: newBalances, }, - assetsMetadata, + assetsInfo, }; log('Balance update response', { @@ -507,7 +479,7 @@ export class RpcDataSource extends AbstractDataSource< detectedAssets: { [result.accountId]: result.detectedAssets.map((asset) => asset.assetId), }, - assetsMetadata: newMetadata, + assetsInfo: newMetadata, assetsBalance: { [result.accountId]: newBalances, }, @@ -521,11 +493,7 @@ export class RpcDataSource extends AbstractDataSource< } #subscribeToNetworkController(): void { - ( - this.#messenger as unknown as { - subscribe: (e: string, h: (s: NetworkState) => void) => void; - } - ).subscribe( + this.#messenger.subscribe( 'NetworkController:stateChange', (networkState: NetworkState) => { log('NetworkController state changed'); @@ -535,12 +503,108 @@ export class RpcDataSource extends AbstractDataSource< ); } + #subscribeToTransactionEvents(): void { + const unsubConfirmed = this.#messenger.subscribe( + 'TransactionController:transactionConfirmed', + this.#onTransactionConfirmed.bind(this), + ); + this.#unsubscribeTransactionConfirmed = + typeof unsubConfirmed === 'function' ? unsubConfirmed : undefined; + + const unsubIncoming = this.#messenger.subscribe( + 'TransactionController:incomingTransactionsReceived', + this.#onIncomingTransactions.bind(this), + ); + this.#unsubscribeIncomingTransactions = + typeof unsubIncoming === 'function' ? unsubIncoming : undefined; + } + + #onTransactionConfirmed(payload: TransactionMeta): void { + const hexChainId = payload?.chainId; + if (!hexChainId) { + return; + } + const caipChainId = `eip155:${parseInt(hexChainId, 16)}` as ChainId; + this.#refreshBalanceForChains([caipChainId]).catch((error) => { + log('Failed to refresh balance after transaction confirmed', { error }); + }); + } + + #onIncomingTransactions(payload: TransactionMeta[]): void { + const chainIds = Array.from( + new Set( + (payload ?? []) + .map((item) => item?.chainId) + .filter((id): id is Hex => Boolean(id)), + ), + ); + const caipChainIds = chainIds.map( + (hexChainId) => `eip155:${parseInt(hexChainId, 16)}` as ChainId, + ); + const toRefresh = + caipChainIds.length > 0 ? caipChainIds : [...this.#activeChains]; + this.#refreshBalanceForChains(toRefresh).catch((error) => { + log('Failed to refresh balance after incoming transactions', { error }); + }); + } + + /** + * Fetch balances for the given chains across all active subscriptions and + * push updates to the controller. + * + * @param chainIds - CAIP-2 chain IDs to refresh. + */ + async #refreshBalanceForChains(chainIds: ChainId[]): Promise { + const chainIdsSet = new Set(chainIds); + const chainsToFetch = chainIds.filter((chainId) => + this.#activeChains.includes(chainId), + ); + if (chainsToFetch.length === 0) { + return; + } + + for (const subscription of this.#activeSubscriptions.values()) { + const subscriptionChains = subscription.chains.filter((chainId) => + chainIdsSet.has(chainId), + ); + if (subscriptionChains.length === 0) { + continue; + } + + const request: DataRequest = { + accountsWithSupportedChains: subscription.accounts.map((account) => ({ + account, + supportedChains: subscriptionChains, + })), + chainIds: subscriptionChains, + dataTypes: ['balance'], + }; + + try { + const response = await this.fetch(request); + if ( + response.assetsBalance && + Object.keys(response.assetsBalance).length > 0 + ) { + subscription.onAssetsUpdate(response)?.catch((error) => { + log('Failed to report balance update after transaction', { + error, + }); + }); + } + } catch (error) { + log('Failed to fetch balance after transaction', { + chains: subscriptionChains, + error, + }); + } + } + } + #initializeFromNetworkController(): void { log('Initializing from NetworkController'); try { - const networkState = ( - this.#messenger as unknown as { call: (a: string) => NetworkState } - ).call('NetworkController:getState'); + const networkState = this.#messenger.call('NetworkController:getState'); this.#updateFromNetworkState(networkState); } catch (error) { log('Failed to initialize from NetworkController', error); @@ -620,11 +684,7 @@ export class RpcDataSource extends AbstractDataSource< } try { - const networkClient = ( - this.#messenger as unknown as { - call: (a: string, id: string) => NetworkClient; - } - ).call( + const networkClient = this.#messenger.call( 'NetworkController:getNetworkClientById', chainStatus.networkClientId, ); @@ -759,7 +819,7 @@ export class RpcDataSource extends AbstractDataSource< string, Record > = {}; - const assetsMetadata: Record = {}; + const assetsInfo: Record = {}; const failedChains: ChainId[] = []; // Fetch balances for each account and its supported chains (pre-computed in request) @@ -798,11 +858,11 @@ export class RpcDataSource extends AbstractDataSource< result.balances, chainId, ); - Object.assign(assetsMetadata, balanceMetadata); + Object.assign(assetsInfo, balanceMetadata); // Convert balances to human-readable format for (const balance of result.balances) { - const metadata = assetsMetadata[balance.assetId]; + const metadata = assetsInfo[balance.assetId]; // Default to 18 decimals (ERC20 standard) for consistent human-readable format const decimals = metadata?.decimals ?? 18; const humanReadableAmount = this.#convertToHumanReadable( @@ -826,7 +886,7 @@ export class RpcDataSource extends AbstractDataSource< // Even on error, include native token metadata const chainStatus = this.#chainStatuses[chainId]; if (chainStatus) { - assetsMetadata[nativeAssetId] = { + assetsInfo[nativeAssetId] = { type: 'native', symbol: chainStatus.nativeCurrency, name: chainStatus.nativeCurrency, @@ -863,8 +923,8 @@ export class RpcDataSource extends AbstractDataSource< response.assetsBalance = assetsBalance; // Include metadata for native tokens if we have any - if (Object.keys(assetsMetadata).length > 0) { - response.assetsMetadata = assetsMetadata; + if (Object.keys(assetsInfo).length > 0) { + response.assetsInfo = assetsInfo; } return response; @@ -881,7 +941,7 @@ export class RpcDataSource extends AbstractDataSource< chainId: ChainId, account: InternalAccount, ): Promise { - if (!this.#tokenDetectionEnabled) { + if (!this.#tokenDetectionEnabled() || !this.#useExternalService()) { return {}; } @@ -895,6 +955,10 @@ export class RpcDataSource extends AbstractDataSource< hexChainId, accountId, address as Address, + { + tokenDetectionEnabled: this.#tokenDetectionEnabled(), + useExternalService: this.#useExternalService(), + }, ); if (result.detectedAssets.length === 0) { @@ -910,12 +974,12 @@ export class RpcDataSource extends AbstractDataSource< // Convert detected assets to DataResponse format const balances: Record = {}; - const assetsMetadata: Record = {}; + const assetsInfo: Record = {}; // Build metadata from detected assets for (const asset of result.detectedAssets) { if (asset.symbol && asset.decimals !== undefined) { - assetsMetadata[asset.assetId] = { + assetsInfo[asset.assetId] = { type: 'erc20', symbol: asset.symbol, name: asset.name ?? asset.symbol, @@ -952,8 +1016,8 @@ export class RpcDataSource extends AbstractDataSource< }; // Include metadata if we have any - if (Object.keys(assetsMetadata).length > 0) { - response.assetsMetadata = assetsMetadata; + if (Object.keys(assetsInfo).length > 0) { + response.assetsInfo = assetsInfo; } return response; @@ -1000,11 +1064,11 @@ export class RpcDataSource extends AbstractDataSource< } } - if (response.assetsMetadata) { - context.response.assetsMetadata ??= {}; - context.response.assetsMetadata = { - ...context.response.assetsMetadata, - ...response.assetsMetadata, + if (response.assetsInfo) { + context.response.assetsInfo ??= {}; + context.response.assetsInfo = { + ...context.response.assetsInfo, + ...response.assetsInfo, }; } @@ -1101,8 +1165,8 @@ export class RpcDataSource extends AbstractDataSource< const balanceToken = this.#balanceFetcher.startPolling(balanceInput); balancePollingTokens.push(balanceToken); - // Start detection polling if enabled - if (this.#tokenDetectionEnabled) { + // Start detection polling if enabled and external services allowed + if (this.#tokenDetectionEnabled() && this.#useExternalService()) { const detectionInput: DetectionPollingInput = { chainId: hexChainId, accountId, @@ -1169,8 +1233,7 @@ export class RpcDataSource extends AbstractDataSource< 'NetworkEnablementController:getState', ); - return (nativeAssetIdentifiers[chainId] ?? - `${chainId}/slip44:60`) as Caip19AssetId; + return nativeAssetIdentifiers[chainId] ?? `${chainId}/slip44:60`; } /** @@ -1181,13 +1244,8 @@ export class RpcDataSource extends AbstractDataSource< */ #getExistingAssetsMetadata(): Record { try { - const state = this.#messenger.call('AssetsController:getState') as { - assetsMetadata?: Record; - }; - return (state.assetsMetadata ?? {}) as unknown as Record< - Caip19AssetId, - AssetMetadata - >; + const state = this.#messenger.call('AssetsController:getState'); + return state.assetsInfo ?? {}; } catch { // If AssetsController:getState fails, return empty metadata return {}; @@ -1205,22 +1263,18 @@ export class RpcDataSource extends AbstractDataSource< assetId: Caip19AssetId, ): AssetMetadata | undefined { try { - // Parse asset ID to get chain and token address - // Format: eip155:{chainId}/erc20:{address} - const [chainPart, assetPart] = assetId.split('/'); - if (!assetPart?.startsWith('erc20:')) { + const parsed = parseCaipAssetType(assetId); + if (parsed.assetNamespace !== 'erc20') { return undefined; } + const tokenAddress = parsed.assetReference; + const { reference } = parseCaipChainId(parsed.chainId); + const hexChainId = numberToHex(parseInt(reference, 10)); - const tokenAddress = assetPart.slice(6); // Remove 'erc20:' prefix - const chainIdDecimal = chainPart.split(':')[1]; - const hexChainId = `0x${parseInt(chainIdDecimal, 10).toString(16)}`; - - const tokenListState = ( - this.#messenger as unknown as { call: (a: string) => TokenListState } - ).call('TokenListController:getState'); - const chainCacheEntry = - tokenListState?.tokensChainsCache?.[hexChainId as `0x${string}`]; + const tokenListState = this.#messenger.call( + 'TokenListController:getState', + ); + const chainCacheEntry = tokenListState?.tokensChainsCache?.[hexChainId]; const chainTokenList = chainCacheEntry?.data; if (!chainTokenList) { @@ -1231,12 +1285,7 @@ export class RpcDataSource extends AbstractDataSource< const lowerAddress = tokenAddress.toLowerCase(); for (const [address, tokenData] of Object.entries(chainTokenList)) { if (address.toLowerCase() === lowerAddress) { - const token = tokenData as { - symbol?: string; - name?: string; - decimals?: number; - iconUrl?: string; - }; + const token = tokenData; if (token.symbol && token.decimals !== undefined) { return { type: 'erc20', @@ -1261,6 +1310,9 @@ export class RpcDataSource extends AbstractDataSource< destroy(): void { log('Destroying RpcDataSource'); + this.#unsubscribeTransactionConfirmed?.(); + this.#unsubscribeIncomingTransactions?.(); + // Stop all polling this.#balanceFetcher.stopAllPolling(); this.#tokenDetector.stopAllPolling(); diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts index 8168e9ae051..561cfd440da 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.test.ts @@ -103,7 +103,7 @@ function createMiddlewareContext(overrides?: Partial): Context { return { request: createDataRequest(), response: {}, - getAssetsState: jest.fn().mockReturnValue({ assetsMetadata: {} }), + getAssetsState: jest.fn().mockReturnValue({ assetsInfo: {} }), ...overrides, }; } @@ -434,7 +434,7 @@ describe('SnapDataSource', () => { expect(response).toStrictEqual({ assetsBalance: {}, - assetsMetadata: {}, + assetsInfo: {}, }); cleanup(); @@ -468,7 +468,7 @@ describe('SnapDataSource', () => { // No accounts to fetch, so empty balances expect(response).toStrictEqual({ assetsBalance: {}, - assetsMetadata: {}, + assetsInfo: {}, }); cleanup(); @@ -633,6 +633,39 @@ describe('SnapDataSource', () => { cleanup(); }); + it('skips malformed asset IDs in balance update and still applies valid balances', async () => { + const { triggerBalancesUpdated, assetsUpdateHandler, cleanup } = + setupController({ + installedSnaps: { + [SOLANA_SNAP_ID]: { version: '1.0.0', chainIds: [SOLANA_MAINNET] }, + }, + }); + await new Promise(process.nextTick); + + triggerBalancesUpdated({ + balances: { + 'account-1': { + 'not-a-valid-caip19': { amount: '999', unit: 'FAKE' }, + [MOCK_SOL_ASSET]: { amount: '1000000000', unit: 'SOL' }, + }, + }, + }); + + await new Promise(process.nextTick); + + expect(assetsUpdateHandler).toHaveBeenCalledWith( + expect.objectContaining({ + assetsBalance: { + 'account-1': { + [MOCK_SOL_ASSET]: { amount: '1000000000' }, + }, + }, + }), + ); + + cleanup(); + }); + it('does not report empty balance updates', async () => { const { triggerBalancesUpdated, assetsUpdateHandler, cleanup } = setupController({ diff --git a/packages/assets-controller/src/data-sources/SnapDataSource.ts b/packages/assets-controller/src/data-sources/SnapDataSource.ts index 04dcb4b57db..823daae02bd 100644 --- a/packages/assets-controller/src/data-sources/SnapDataSource.ts +++ b/packages/assets-controller/src/data-sources/SnapDataSource.ts @@ -13,6 +13,7 @@ import type { } from '@metamask/snaps-controllers'; import type { Snap, SnapId } from '@metamask/snaps-sdk'; import { HandlerType, SnapCaveatType } from '@metamask/snaps-utils'; +import { parseCaipAssetType } from '@metamask/utils'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { AbstractDataSource } from './AbstractDataSource'; @@ -101,15 +102,16 @@ export function getChainIdsCaveat( } /** - * Extract chain ID from a CAIP-19 asset ID. + * Extracts the CAIP-2 chain ID from a CAIP-19 asset ID. * e.g., "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501" -> "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + * Uses @metamask/utils parseCaipAssetType for CAIP parsing. * * @param assetId - The CAIP-19 asset ID to extract chain from. * @returns The CAIP-2 chain ID portion of the asset ID. */ export function extractChainFromAssetId(assetId: string): ChainId { - const parts = assetId.split('/'); - return parts[0] as ChainId; + const parsed = parseCaipAssetType(assetId as CaipAssetType); + return parsed.chainId; } // ============================================================================ @@ -270,7 +272,16 @@ export class SnapDataSource extends AbstractDataSource< let accountAssets: Record | undefined; for (const [assetId, balance] of Object.entries(assets)) { - const chainId = extractChainFromAssetId(assetId); + let chainId: ChainId; + try { + chainId = extractChainFromAssetId(assetId); + } catch (error) { + log('Skipping snap balance for malformed asset ID', { + assetId, + error, + }); + continue; + } if (this.#isChainSupportedBySnap(chainId)) { accountAssets ??= {}; accountAssets[assetId as Caip19AssetId] = { @@ -428,12 +439,12 @@ export class SnapDataSource extends AbstractDataSource< return {}; } if (!request?.accountsWithSupportedChains?.length) { - return { assetsBalance: {}, assetsMetadata: {} }; + return { assetsBalance: {}, assetsInfo: {} }; } const results: DataResponse = { assetsBalance: {}, - assetsMetadata: {}, + assetsInfo: {}, }; // Fetch balances for each account using its snap ID from metadata @@ -541,10 +552,10 @@ export class SnapDataSource extends AbstractDataSource< }; } } - if (response.assetsMetadata) { - context.response.assetsMetadata = { - ...context.response.assetsMetadata, - ...response.assetsMetadata, + if (response.assetsInfo) { + context.response.assetsInfo = { + ...context.response.assetsInfo, + ...response.assetsInfo, }; } if (response.assetsPrice) { diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts new file mode 100644 index 00000000000..cadf81c1ae4 --- /dev/null +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.test.ts @@ -0,0 +1,622 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { TransactionStatus } from '@metamask/transaction-controller'; + +import type { StakedBalanceDataSourceOptions } from './StakedBalanceDataSource'; +import { StakedBalanceDataSource } from './StakedBalanceDataSource'; +import { + MockRootMessenger, + createMockAssetControllerMessenger, + createMockWeb3Provider, + registerStakedMessengerActions, +} from '../__fixtures__/MockAssetControllerMessenger'; +import type { AssetsControllerMessenger } from '../AssetsController'; +import type { + AssetsControllerStateInternal, + ChainId, + Context, + DataRequest, +} from '../types'; + +const MAINNET_CHAIN_ID_HEX = '0x1'; +const MAINNET_CHAIN_ID_CAIP = 'eip155:1' as ChainId; +const STAKING_CONTRACT_MAINNET = '0x4FEF9D741011476750A243aC70b9789a63dd47Df'; +const MOCK_ACCOUNT_ID = 'mock-account-id'; +const MOCK_ADDRESS = '0x1234567890123456789012345678901234567890'; + +function createMockInternalAccount( + overrides?: Partial, +): InternalAccount { + return { + id: MOCK_ACCOUNT_ID, + address: MOCK_ADDRESS, + options: {}, + methods: [], + type: 'eip155:eoa', + scopes: [MAINNET_CHAIN_ID_CAIP], + metadata: { + name: 'Test Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + lastSelected: Date.now(), + }, + ...overrides, + } as InternalAccount; +} + +function createDataRequest( + overrides?: Partial & { accounts?: InternalAccount[] }, +): DataRequest { + const chainIds = overrides?.chainIds ?? [MAINNET_CHAIN_ID_CAIP]; + const accounts = overrides?.accounts ?? [createMockInternalAccount()]; + const { accounts: _a, ...rest } = overrides ?? {}; + return { + chainIds, + accountsWithSupportedChains: accounts.map((a) => ({ + account: a, + supportedChains: chainIds, + })), + dataTypes: ['balance'], + ...rest, + }; +} + +function getMockAssetsState(): AssetsControllerStateInternal { + return { + assetsInfo: {}, + assetsBalance: {}, + assetsPrice: {}, + customAssets: {}, + assetPreferences: {}, + }; +} + +function createMiddlewareContext(overrides?: Partial): Context { + return { + request: createDataRequest(), + response: {}, + getAssetsState: getMockAssetsState, + ...overrides, + }; +} + +type WithControllerOptions = { + options?: Partial; + enabledNetworkMap?: Record>; + mockProvider?: ReturnType; +}; + +type WithControllerCallback = ({ + controller, + messenger, + onActiveChainsUpdated, + mockProvider, +}: { + controller: StakedBalanceDataSource; + messenger: AssetsControllerMessenger; + mockMessengerCall: jest.SpyInstance; + mockMessengerSubscribe: jest.SpyInstance; + mockMessengerUnsubscribe: jest.SpyInstance; + rootMessenger: MockRootMessenger; + onActiveChainsUpdated: ( + dataSourceName: string, + chains: ChainId[], + previousChains: ChainId[], + ) => void; + mockProvider: ReturnType; +}) => Promise | ReturnValue; + +async function withController( + ...args: + | [WithControllerOptions, WithControllerCallback] + | [WithControllerCallback] +): Promise { + const [controllerOptions, fn] = args.length === 2 ? args : [{}, args[0]]; + const { + options = {}, + enabledNetworkMap = { eip155: { [MAINNET_CHAIN_ID_HEX]: true } }, + mockProvider = createMockWeb3Provider({ + sharesWei: '1000000000000000000', + assetsWei: '1500000000000000000', + }), + } = controllerOptions; + + const { assetsControllerMessenger, rootMessenger } = + createMockAssetControllerMessenger(); + registerStakedMessengerActions(rootMessenger, { + enabledNetworkMap, + mockProvider, + }); + + // spy on staked messenger calls, so we can inspect and assert + const mockStakedMessengerCall = jest.spyOn(assetsControllerMessenger, 'call'); + + // spy on staked messenger subscriptions, so we can inspect and assert + const mockStakedMessengerSubscribe = jest.spyOn( + assetsControllerMessenger, + 'subscribe', + ); + + // spy on staked messenger unsubscribe, so we can inspect and assert + const mockStakedMessengerUnsubscribe = jest.spyOn( + assetsControllerMessenger, + 'clearEventSubscriptions', + ); + + const onActiveChainsUpdated = + ( + options as { + onActiveChainsUpdated?: (n: string, c: ChainId[], p: ChainId[]) => void; + } + ).onActiveChainsUpdated ?? jest.fn(); + + const controller = new StakedBalanceDataSource({ + messenger: assetsControllerMessenger, + onActiveChainsUpdated, + ...options, + pollInterval: 1000, + }); + + try { + return await fn({ + controller, + messenger: assetsControllerMessenger, + mockMessengerCall: mockStakedMessengerCall, + mockMessengerSubscribe: mockStakedMessengerSubscribe, + mockMessengerUnsubscribe: mockStakedMessengerUnsubscribe, + onActiveChainsUpdated, + mockProvider, + rootMessenger, + }); + } finally { + controller.destroy(); + } +} + +describe('StakedBalanceDataSource', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('initializes with default options', async () => { + await withController(({ controller }) => { + expect(controller).toBeInstanceOf(StakedBalanceDataSource); + expect(controller.getName()).toBe('StakedBalanceDataSource'); + }); + }); + + it('initializes with custom poll interval', async () => { + await withController( + { options: { pollInterval: 60_000 } }, + ({ controller }) => { + expect(controller).toBeDefined(); + }, + ); + }); + + it('initializes with enabled: false and has no active chains', async () => { + await withController( + { options: { enabled: false }, enabledNetworkMap: {} }, + async ({ controller }) => { + expect(controller).toBeDefined(); + expect(await controller.getActiveChains()).toStrictEqual([]); + }, + ); + }); + + it('calls onActiveChainsUpdated with active staking chains when mainnet is enabled', async () => { + await withController(({ onActiveChainsUpdated }) => { + expect(onActiveChainsUpdated).toHaveBeenCalledWith( + 'StakedBalanceDataSource', + expect.arrayContaining([MAINNET_CHAIN_ID_CAIP]), + [], + ); + }); + }); + + it('subscribes to transaction and network events', async () => { + await withController(({ mockMessengerSubscribe }) => { + expect(mockMessengerSubscribe).toHaveBeenCalledWith( + 'TransactionController:transactionConfirmed', + expect.any(Function), + ); + expect(mockMessengerSubscribe).toHaveBeenCalledWith( + 'TransactionController:incomingTransactionsReceived', + expect.any(Function), + ); + expect(mockMessengerSubscribe).toHaveBeenCalledWith( + 'NetworkController:stateChange', + expect.any(Function), + ); + expect(mockMessengerSubscribe).toHaveBeenCalledWith( + 'NetworkEnablementController:stateChange', + expect.any(Function), + ); + }); + }); + }); + + describe('getName', () => { + it('returns the data source name', async () => { + await withController(({ controller }) => { + expect(controller.getName()).toBe('StakedBalanceDataSource'); + }); + }); + }); + + describe('getActiveChainsSync', () => { + it('returns active chains when mainnet is enabled', async () => { + await withController(async ({ controller }) => { + const chains = await controller.getActiveChains(); + expect(chains).toContain(MAINNET_CHAIN_ID_CAIP); + }); + }); + + it('returns empty array when no staking chains are enabled', async () => { + await withController( + { enabledNetworkMap: { eip155: {} } }, + async ({ controller }) => { + const chains = await controller.getActiveChains(); + expect(chains).toHaveLength(0); + }, + ); + }); + }); + + describe('fetch', () => { + it('returns empty response when disabled', async () => { + await withController( + { options: { enabled: false }, enabledNetworkMap: {} }, + async ({ controller }) => { + const request = createDataRequest(); + const response = await controller.fetch(request); + expect(response).toStrictEqual({}); + }, + ); + }); + + it('returns empty response when no active chains', async () => { + await withController( + { enabledNetworkMap: { eip155: {} } }, + async ({ controller }) => { + const request = createDataRequest(); + const response = await controller.fetch(request); + expect(response).toStrictEqual({}); + }, + ); + }); + + it('returns empty response for unsupported chain', async () => { + await withController(async ({ controller }) => { + const request = createDataRequest({ + chainIds: ['eip155:999' as ChainId], + accountsWithSupportedChains: [ + { + account: createMockInternalAccount(), + supportedChains: ['eip155:999' as ChainId], + }, + ], + }); + const response = await controller.fetch(request); + expect(response).toStrictEqual({}); + }); + }); + + it('returns staked balance and metadata for mainnet when fetcher returns data', async () => { + await withController( + async ({ controller, mockMessengerCall: mockMessengerCalls }) => { + const account = createMockInternalAccount(); + const request = createDataRequest({ + accounts: [account], + chainIds: [MAINNET_CHAIN_ID_CAIP], + accountsWithSupportedChains: [ + { account, supportedChains: [MAINNET_CHAIN_ID_CAIP] }, + ], + }); + + const response = await controller.fetch(request); + expect(response).toBeDefined(); + + expect(mockMessengerCalls).toHaveBeenCalledWith( + 'NetworkController:getNetworkClientById', + 'mainnet', + ); + }, + ); + }); + + it('returns zero amount when getShares returns zero', async () => { + await withController( + { + mockProvider: createMockWeb3Provider({ + sharesWei: '0', + assetsWei: '0', + }), + }, + async ({ controller }) => { + const account = createMockInternalAccount(); + const request = createDataRequest({ + accounts: [account], + chainIds: [MAINNET_CHAIN_ID_CAIP], + accountsWithSupportedChains: [ + { account, supportedChains: [MAINNET_CHAIN_ID_CAIP] }, + ], + }); + const response = await controller.fetch(request); + expect(response).toBeDefined(); + }, + ); + }); + }); + + describe('subscribe', () => { + it('stores subscription and calls onAssetsUpdate after initial fetch', async () => { + await withController(async ({ controller }) => { + const onAssetsUpdate = jest.fn(); + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + getAssetsState: getMockAssetsState, + }); + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + expect(await controller.getActiveChains()).toContain( + MAINNET_CHAIN_ID_CAIP, + ); + await controller.unsubscribe('test-sub'); + }); + }); + + it('does not call onAssetsUpdate when no staking chains to subscribe', async () => { + await withController( + { enabledNetworkMap: { eip155: {} } }, + async ({ controller }) => { + const onAssetsUpdate = jest.fn(); + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + getAssetsState: getMockAssetsState, + }); + expect(onAssetsUpdate).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('unsubscribe', () => { + it('removes subscription and stops polling', async () => { + await withController(async ({ controller }) => { + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate: jest.fn(), + getAssetsState: getMockAssetsState, + }); + await controller.unsubscribe('test-sub'); + const chains = await controller.getActiveChains(); + expect(chains.length).toBeGreaterThan(0); + }); + }); + }); + + describe('transaction events', () => { + const arrange = async (props: { + controller: StakedBalanceDataSource; + }): Promise => { + // subscribe and wait ensure polling finishes before we start test + const onAssetsUpdate = jest.fn(); + await props.controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + getAssetsState: getMockAssetsState, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + onAssetsUpdate.mockClear(); + + return onAssetsUpdate; + }; + + it('refreshes staked balance when transactionConfirmed involves staking contract (to)', async () => { + await withController(async ({ controller, rootMessenger }) => { + // Arrange + const onAssetsUpdate = await arrange({ controller }); + + // Act + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: '1', + networkClientId: 'mainnet', + status: TransactionStatus.confirmed, + time: Date.now(), + chainId: MAINNET_CHAIN_ID_HEX, + txParams: { + to: STAKING_CONTRACT_MAINNET, + from: '0x0000000000000000000000000000000000000000', + }, + }); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + }); + }); + + it('does not refresh when transactionConfirmed does not involve staking contract', async () => { + await withController(async ({ controller, rootMessenger }) => { + // Arrange + const onAssetsUpdate = await arrange({ controller }); + + // Act + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: '1', + networkClientId: 'mainnet', + status: TransactionStatus.confirmed, + time: Date.now(), + chainId: MAINNET_CHAIN_ID_HEX, + txParams: { + from: '0xabcdef1234567890abcdef1234567890abcdef12', + to: '0x1234567890123456789012345678901234567890', + }, + }); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onAssetsUpdate).not.toHaveBeenCalled(); + }); + }); + + it('refreshes when transactionConfirmed has from equal to staking contract', async () => { + await withController(async ({ controller, rootMessenger }) => { + // Arrange + const onAssetsUpdate = await arrange({ controller }); + + // Act + rootMessenger.publish('TransactionController:transactionConfirmed', { + id: '1', + networkClientId: 'mainnet', + status: TransactionStatus.confirmed, + time: Date.now(), + chainId: MAINNET_CHAIN_ID_HEX, + txParams: { from: STAKING_CONTRACT_MAINNET.toLowerCase() }, + }); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + }); + }); + + it('refreshes when incomingTransactionsReceived includes tx involving staking contract', async () => { + await withController(async ({ controller, rootMessenger }) => { + // Arrange + const onAssetsUpdate = await arrange({ controller }); + + // Act + rootMessenger.publish( + 'TransactionController:incomingTransactionsReceived', + [ + { + id: '1', + networkClientId: 'mainnet', + status: TransactionStatus.confirmed, + time: Date.now(), + chainId: MAINNET_CHAIN_ID_HEX, + txParams: { + to: STAKING_CONTRACT_MAINNET, + from: '0x0000000000000000000000000000000000000000', + }, + }, + ], + ); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(onAssetsUpdate).toHaveBeenCalledTimes(1); + }); + }); + + it('does not refresh when incomingTransactionsReceived has no tx involving staking contract', async () => { + await withController(async ({ controller, rootMessenger }) => { + // Arrange + const onAssetsUpdate = await arrange({ controller }); + + // Act + rootMessenger.publish( + 'TransactionController:incomingTransactionsReceived', + [ + { + id: '1', + networkClientId: 'mainnet', + status: TransactionStatus.confirmed, + time: Date.now(), + chainId: MAINNET_CHAIN_ID_HEX, + txParams: { + to: '0x1234567890123456789012345678901234567890', + from: '0x0000000000000000000000000000000000000000', + }, + }, + ], + ); + + // Assert + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onAssetsUpdate).not.toHaveBeenCalled(); + }); + }); + }); + + describe('refreshStakedBalance', () => { + it('pushes updates for all subscribed accounts and chains', async () => { + await withController(async ({ controller }) => { + const onAssetsUpdate = jest.fn(); + await controller.subscribe({ + request: createDataRequest(), + subscriptionId: 'test-sub', + isUpdate: false, + onAssetsUpdate, + getAssetsState: getMockAssetsState, + }); + onAssetsUpdate.mockClear(); + expect(await controller.refreshStakedBalance()).toBeUndefined(); + }); + }); + + it('does nothing when disabled', async () => { + await withController( + { options: { enabled: false }, enabledNetworkMap: {} }, + async ({ controller }) => { + expect(await controller.refreshStakedBalance()).toBeUndefined(); + }, + ); + }); + }); + + describe('assetsMiddleware', () => { + it('passes through when disabled', async () => { + await withController( + { options: { enabled: false }, enabledNetworkMap: {} }, + async ({ controller }) => { + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext(); + await controller.assetsMiddleware(context, next); + expect(next).toHaveBeenCalledWith(context); + expect(context.response).toStrictEqual({}); + }, + ); + }); + + it('merges staked balance into response when balance data type requested', async () => { + await withController(async ({ controller }) => { + const next = jest.fn().mockResolvedValue(undefined); + const context = createMiddlewareContext(); + await controller.assetsMiddleware(context, next); + expect(next).toHaveBeenCalledWith(context); + const hasBalance = + context.response.assetsBalance && + Object.keys(context.response.assetsBalance).length > 0; + expect(!hasBalance || context.response.assetsInfo !== undefined).toBe( + true, + ); + }); + }); + + it('passes through without fetching when dataTypes does not include balance', async () => { + await withController(async ({ controller }) => { + const next = jest.fn().mockResolvedValue(undefined); + const request = createDataRequest(); + request.dataTypes = ['price']; + const context = createMiddlewareContext({ request, response: {} }); + await controller.assetsMiddleware(context, next); + expect(next).toHaveBeenCalledWith(context); + expect(context.response.assetsBalance).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts new file mode 100644 index 00000000000..87eab17512c --- /dev/null +++ b/packages/assets-controller/src/data-sources/StakedBalanceDataSource.ts @@ -0,0 +1,911 @@ +import { toChecksumAddress } from '@ethereumjs/util'; +import { Web3Provider } from '@ethersproject/providers'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; +import type { NetworkEnablementControllerState } from '@metamask/network-enablement-controller'; +import { + isStrictHexString, + isCaipChainId, + numberToHex, + parseCaipChainId, +} from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { + DataSourceState, + SubscriptionRequest, +} from './AbstractDataSource'; +import { AbstractDataSource } from './AbstractDataSource'; +import type { + StakedBalancePollingInput, + StakedBalanceFetchResult, +} from './evm-rpc-services'; +import { + StakedBalanceFetcher, + getStakingContractAddress, + getSupportedStakingChainIds, +} from './evm-rpc-services'; +import type { AssetsControllerMessenger } from '../AssetsController'; +import { projectLogger, createModuleLogger } from '../logger'; +import type { + AccountId, + ChainId, + Caip19AssetId, + AssetBalance, + AssetMetadata, + DataRequest, + DataResponse, + Middleware, +} from '../types'; + +const CONTROLLER_NAME = 'StakedBalanceDataSource'; +const DEFAULT_POLL_INTERVAL = 180_000; // 3 minutes + +/** Metadata for staked ETH (same symbol and decimals as native ETH). */ +const STAKED_ETH_METADATA: AssetMetadata = { + type: 'erc20', + name: 'staked ethereum', + symbol: 'ETH', + decimals: 18, +}; + +const log = createModuleLogger(projectLogger, CONTROLLER_NAME); + +/** Optional configuration for StakedBalanceDataSource. */ +export type StakedBalanceDataSourceConfig = { + /** Whether staked balance fetching is enabled (default: true). */ + enabled?: boolean; + /** Polling interval in ms (default: 180s / 3 min). */ + pollInterval?: number; +}; + +export type StakedBalanceDataSourceOptions = StakedBalanceDataSourceConfig & { + /** The AssetsController messenger (for accessing NetworkController). */ + messenger: AssetsControllerMessenger; + /** Called when active chains are updated. */ + onActiveChainsUpdated: ( + dataSourceName: string, + chains: ChainId[], + previousChains: ChainId[], + ) => void; +}; + +/** Per-account supported chains (same shape as in DataRequest). */ +type AccountWithSupportedChains = { + account: InternalAccount; + supportedChains: ChainId[]; +}; + +/** + * Subscription data stored for each active subscription. + * Stores accountsWithSupportedChains so refresh paths use the same per-account + * scope as normal subscription setup (avoids querying unsupported chains/accounts). + */ +type SubscriptionData = { + /** Polling tokens from StakedBalanceFetcher. */ + pollingTokens: string[]; + /** Chain IDs being polled (union of all account chains). */ + chains: ChainId[]; + /** Accounts being polled. */ + accounts: InternalAccount[]; + /** Per-account supported chains; used by refreshStakedBalance and transaction handlers. */ + accountsWithSupportedChains: AccountWithSupportedChains[]; + /** Callback to report asset updates. */ + onAssetsUpdate: (response: DataResponse) => void | Promise; +}; + +/** + * Convert CAIP chain ID or hex chain ID to hex chain ID. + * + * @param chainId - CAIP chain ID or hex chain ID. + * @returns Hex chain ID. + */ +function caipChainIdToHex(chainId: string): Hex { + if (isStrictHexString(chainId)) { + return chainId; + } + + if (isCaipChainId(chainId)) { + const ref = parseCaipChainId(chainId).reference; + return numberToHex(parseInt(ref, 10)); + } + + throw new Error('caipChainIdToHex - Failed to provide CAIP-2 or Hex chainId'); +} + +/** + * Build the CAIP-19 asset ID for staked balance (same format as ERC20). + * Uses the staking contract address (checksummed) so it is consistent with + * other token assets. + * Example: "eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df" + * + * @param chainId - CAIP-2 chain ID (e.g. "eip155:1"). + * @param contractAddress - Staking contract address (hex). + * @returns The staked asset CAIP-19 ID with checksummed address. + */ +function stakedAssetId( + chainId: ChainId, + contractAddress: string, +): Caip19AssetId { + const checksummed = toChecksumAddress(contractAddress); + return `${chainId}/erc20:${checksummed}` as Caip19AssetId; +} + +/** + * Data source for fetching staked ETH balances via on-chain staking contracts. + * + * Delegates to {@link StakedBalanceFetcher} for the actual RPC calls + * (getShares + convertToAssets on ERC-4626-style staking contracts). + * Reports balances as CAIP-19 asset IDs using the ERC20 format with the + * staking contract address (e.g. "eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df"). + * + * Only supports chains with known staking contracts (mainnet, Hoodi). + * Polling is managed by StakedBalanceFetcher via startPolling/stopPollingByPollingToken. + */ +export class StakedBalanceDataSource extends AbstractDataSource< + typeof CONTROLLER_NAME, + DataSourceState +> { + readonly #messenger: AssetsControllerMessenger; + + readonly #onActiveChainsUpdated: ( + dataSourceName: string, + chains: ChainId[], + previousChains: ChainId[], + ) => void; + + readonly #pollInterval: number; + + readonly #enabled: boolean; + + /** The StakedBalanceFetcher that handles polling and RPC calls. */ + readonly #stakedBalanceFetcher: StakedBalanceFetcher; + + /** Active subscriptions by ID. */ + readonly #activeSubscriptions: Map = new Map(); + + /** Cache of Web3Provider instances by hex chain ID. */ + readonly #providerCache: Map = new Map(); + + /** CAIP-2 chain IDs that have known staking contracts. */ + readonly #supportedChainIds: ChainId[]; + + constructor(options: StakedBalanceDataSourceOptions) { + super(CONTROLLER_NAME, { activeChains: [] }); + this.#messenger = options.messenger; + this.#onActiveChainsUpdated = options.onActiveChainsUpdated; + this.#pollInterval = options.pollInterval ?? DEFAULT_POLL_INTERVAL; + this.#enabled = options.enabled !== false; + this.#supportedChainIds = getSupportedStakingChainIds() as ChainId[]; + + log('Initializing StakedBalanceDataSource', { + enabled: this.#enabled, + pollInterval: this.#pollInterval, + }); + + // Create StakedBalanceFetcher with provider getter + this.#stakedBalanceFetcher = new StakedBalanceFetcher({ + pollingInterval: this.#pollInterval, + getNetworkProvider: (hexChainId): Web3Provider | undefined => + this.#getProvider(hexChainId), + }); + + // Wire the callback so polling results flow back to subscriptions + this.#stakedBalanceFetcher.setOnStakedBalanceUpdate( + this.#handleStakedBalanceUpdate.bind(this), + ); + + this.#messenger.subscribe( + 'TransactionController:transactionConfirmed', + this.#onTransactionConfirmed.bind(this), + ); + + this.#messenger.subscribe( + 'TransactionController:incomingTransactionsReceived', + this.#onIncomingTransactions.bind(this), + ); + + this.#messenger.subscribe( + 'NetworkController:stateChange', + this.#onNetworkStateChange.bind(this), + ); + + this.#messenger.subscribe( + 'NetworkEnablementController:stateChange', + this.#onNetworkEnablementControllerStateChange.bind(this), + ); + + this.#initializeActiveChains(); + } + + /** + * When NetworkController state changes (e.g. RPC endpoints or network clients + * reconfigured), clear the provider cache so subsequent fetches use fresh + * providers. + */ + #onNetworkStateChange(): void { + this.#providerCache.clear(); + log('Provider cache cleared after network state change'); + } + + /** + * When NetworkEnablementController state changes (user enables/disables + * networks), recompute active chains so we only fetch for enabled staking chains. + * + * @param state - The new NetworkEnablementController state. + */ + #onNetworkEnablementControllerStateChange( + state: NetworkEnablementControllerState, + ): void { + const { enabledNetworkMap } = state ?? {}; + if (!enabledNetworkMap) { + return; + } + this.#initializeActiveChainsFromEnabledMap(enabledNetworkMap); + } + + /** + * Returns true if the transaction involves the staking contract (from or to) + * for the payload's chain, so we only refresh staked balance when relevant. + * + * @param payload - Transaction payload. + * @param payload.chainId - Hex chain ID. + * @param payload.txParams - Optional transaction params. + * @param payload.txParams.from - Sender address. + * @param payload.txParams.to - Recipient address. + * @returns True if txParams.from or txParams.to matches the staking contract address. + */ + #isTransactionInvolvingStakingContract(payload: { + chainId?: string; + txParams?: { from?: string; to?: string }; + }): boolean { + const hexChainId = payload?.chainId; + if (!hexChainId) { + return false; + } + const contractAddress = getStakingContractAddress(hexChainId); + if (!contractAddress) { + return false; + } + const contractLower = contractAddress.toLowerCase(); + const from = payload.txParams?.from?.toLowerCase(); + const to = payload.txParams?.to?.toLowerCase(); + return from === contractLower || to === contractLower; + } + + /** + * When a transaction is confirmed, refresh staked balance only if the + * transaction is from or to the staking contract for that chain. + * + * @param payload - From TransactionController:transactionConfirmed. + * @param payload.chainId - Hex chain ID of the transaction. + * @param payload.txParams - Optional transaction params. + * @param payload.txParams.from - Sender address. + * @param payload.txParams.to - Recipient address. + */ + #onTransactionConfirmed(payload: { + chainId?: string; + txParams?: { from?: string; to?: string }; + }): void { + if (!this.#enabled) { + return; + } + if (!this.#isTransactionInvolvingStakingContract(payload)) { + return; + } + const hexChainId = payload?.chainId; + if (!hexChainId) { + return; + } + const caipChainId = `eip155:${parseInt(hexChainId, 16)}` as ChainId; + const toRefresh = this.#getToRefreshForChains([caipChainId]); + if (toRefresh.length > 0) { + this.#refreshStakedBalanceAfterTransaction(toRefresh).catch((error) => { + log('Failed to refresh staked balance after transaction', { error }); + }); + } + } + + /** + * When incoming transactions are received, refresh staked balance only for + * chains where at least one transaction is from or to the staking contract. + * + * @param payload - From TransactionController:incomingTransactionsReceived (array of { chainId?, txParams? }). + */ + #onIncomingTransactions( + payload: { chainId?: string; txParams?: { from?: string; to?: string } }[], + ): void { + if (!this.#enabled) { + return; + } + const chainIdsToRefresh = new Set(); + for (const item of payload ?? []) { + if (!item?.chainId) { + continue; + } + if (this.#isTransactionInvolvingStakingContract(item)) { + chainIdsToRefresh.add(item.chainId); + } + } + const caipChainIds = [...chainIdsToRefresh].map( + (hexChainId) => `eip155:${parseInt(hexChainId, 16)}` as ChainId, + ); + if (caipChainIds.length === 0) { + return; + } + const toRefresh = this.#getToRefreshForChains(caipChainIds); + if (toRefresh.length > 0) { + this.#refreshStakedBalanceAfterTransaction(toRefresh).catch((error) => { + log('Failed to refresh staked balance after incoming transactions', { + error, + }); + }); + } + } + + /** + * Build toRefresh list for subscribed (account, chainId) pairs for the given chains. + * + * @param chainIds - CAIP-2 chain IDs to target. + * @returns Pairs of account and chainId to refresh. + */ + #getToRefreshForChains( + chainIds: ChainId[], + ): { account: InternalAccount; chainId: ChainId }[] { + const toRefresh: { account: InternalAccount; chainId: ChainId }[] = []; + const chainSet = new Set(chainIds); + for (const subscription of this.#activeSubscriptions.values()) { + for (const { + account, + supportedChains, + } of subscription.accountsWithSupportedChains) { + for (const chainId of supportedChains) { + if (chainSet.has(chainId)) { + toRefresh.push({ account, chainId }); + } + } + } + } + return toRefresh; + } + + /** + * Build toRefresh list for all subscribed (account, chainId) pairs. + * + * @returns Pairs of account and chainId to refresh. + */ + #getToRefreshAll(): { account: InternalAccount; chainId: ChainId }[] { + const toRefresh: { account: InternalAccount; chainId: ChainId }[] = []; + for (const subscription of this.#activeSubscriptions.values()) { + for (const { + account, + supportedChains, + } of subscription.accountsWithSupportedChains) { + for (const chainId of supportedChains) { + toRefresh.push({ account, chainId }); + } + } + } + return toRefresh; + } + + /** + * Refresh staked balance for all currently subscribed accounts and chains, then + * push updates to the controller. Can be called from UI or after transaction events. + */ + async refreshStakedBalance(): Promise { + if (!this.#enabled) { + return; + } + const toRefresh = this.#getToRefreshAll(); + if (toRefresh.length > 0) { + await this.#refreshStakedBalanceAfterTransaction(toRefresh); + } + } + + /** + * Fetch staked balance for the given account/chain pairs and push a single + * DataResponse to all active subscriptions. + * + * @param toRefresh - List of { account, chainId } to refresh. + */ + async #refreshStakedBalanceAfterTransaction( + toRefresh: { account: InternalAccount; chainId: ChainId }[], + ): Promise { + const assetsInfo: Record = {}; + const assetsBalance: Record< + AccountId, + Record + > = {}; + + for (const { account, chainId } of toRefresh) { + try { + const hexChainId = caipChainIdToHex(chainId); + const contractAddress = getStakingContractAddress(hexChainId); + if (!contractAddress) { + continue; + } + + const input: StakedBalancePollingInput = { + chainId: hexChainId, + accountId: account.id, + accountAddress: account.address as Hex, + }; + + const result = + await this.#stakedBalanceFetcher.fetchStakedBalance(input); + const assetId = stakedAssetId(chainId, contractAddress); + + assetsInfo[assetId] = STAKED_ETH_METADATA; + const existing = assetsBalance[account.id]; + assetsBalance[account.id] = { + ...existing, + [assetId]: { amount: result.amount }, + }; + } catch (error) { + log('Failed to fetch staked balance in transaction refresh', { + chainId, + accountId: account.id, + error, + }); + } + } + + if (Object.keys(assetsBalance).length > 0) { + const response: DataResponse = { assetsInfo, assetsBalance }; + for (const subscription of this.#activeSubscriptions.values()) { + subscription.onAssetsUpdate(response)?.catch((error: unknown) => { + log('Failed to report staked balance update after transaction', { + error, + }); + }); + } + } + } + + /** + * Set active chains from NetworkEnablementController state. + * Only staking-supported chains that are enabled in the network enablement map + * are active (e.g. if mainnet is not selected we do not fetch). + */ + #initializeActiveChains(): void { + try { + const state = this.#messenger.call( + 'NetworkEnablementController:getState', + ); + this.#initializeActiveChainsFromEnabledMap( + state?.enabledNetworkMap ?? {}, + ); + } catch (error) { + log('Failed to get NetworkEnablementController state', { error }); + this.#initializeActiveChainsFromEnabledMap({}); + } + } + + /** + * Compute active chains as the intersection of supported staking chains and + * enabled networks, then update state. Uses the same EIP-155 storage key + * convention as NetworkEnablementController (hex for eip155). + * + * @param enabledNetworkMap - The enabled network map from NetworkEnablementController. + */ + #initializeActiveChainsFromEnabledMap( + enabledNetworkMap: Record>, + ): void { + if (!this.#enabled) { + const previous = [...this.state.activeChains]; + this.updateActiveChains([], (updatedChains) => + this.#onActiveChainsUpdated(this.getName(), updatedChains, previous), + ); + return; + } + + const activeChains: ChainId[] = []; + const eip155Map = enabledNetworkMap.eip155; + if (eip155Map) { + for (const caip2 of this.#supportedChainIds) { + if (!isCaipChainId(caip2)) { + continue; + } + const { reference } = parseCaipChainId(caip2); + const storageKey = numberToHex(parseInt(reference, 10)); + if (eip155Map[storageKey]) { + activeChains.push(caip2); + } + } + } + + const previous = [...this.state.activeChains]; + this.updateActiveChains(activeChains, (updatedChains) => + this.#onActiveChainsUpdated(this.getName(), updatedChains, previous), + ); + } + + /** + * Get or create a Web3Provider for the given hex chain ID. + * Uses the same messenger-cast pattern as RpcDataSource. + * + * @param hexChainId - The hex chain ID. + * @returns Web3Provider instance, or undefined if not available. + */ + #getProvider(hexChainId: Hex): Web3Provider | undefined { + const cached = this.#providerCache.get(hexChainId); + if (cached) { + return cached; + } + + try { + const networkState = this.#messenger.call('NetworkController:getState'); + + const { networkConfigurationsByChainId } = networkState; + if (!networkConfigurationsByChainId) { + return undefined; + } + + const chainConfig = networkConfigurationsByChainId[hexChainId]; + if (!chainConfig) { + return undefined; + } + + // Use the network's configured default RPC endpoint (same as RpcDataSource). + const { rpcEndpoints, defaultRpcEndpointIndex } = chainConfig; + if (!rpcEndpoints || rpcEndpoints.length === 0) { + return undefined; + } + + const index = + typeof defaultRpcEndpointIndex === 'number' && + defaultRpcEndpointIndex >= 0 && + defaultRpcEndpointIndex < rpcEndpoints.length + ? defaultRpcEndpointIndex + : 0; + const defaultEndpoint = rpcEndpoints[index] as { + networkClientId?: string; + }; + const networkClientId = defaultEndpoint?.networkClientId; + if (!networkClientId) { + return undefined; + } + + const networkClient = this.#messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + if (!networkClient?.provider) { + return undefined; + } + + const provider = new Web3Provider(networkClient.provider); + this.#providerCache.set(hexChainId, provider); + return provider; + } catch (error) { + log('Failed to get provider for chain', { hexChainId, error }); + return undefined; + } + } + + /** + * Handle a staked balance update from StakedBalanceFetcher. + * Converts the result into a DataResponse and forwards it to all active + * subscriptions. + * + * @param result - The staked balance fetch result. + */ + #handleStakedBalanceUpdate(result: StakedBalanceFetchResult): void { + const contractAddress = getStakingContractAddress(result.chainId); + if (!contractAddress) { + return; + } + const chainIdDecimal = parseInt(result.chainId, 16); + const caipChainId = `eip155:${chainIdDecimal}` as ChainId; + const assetId = stakedAssetId(caipChainId, contractAddress); + + const response: DataResponse = { + assetsInfo: { + [assetId]: STAKED_ETH_METADATA, + }, + assetsBalance: { + [result.accountId]: { + [assetId]: { amount: result.balance.amount }, + }, + }, + }; + + log('Staked balance update', { + accountId: result.accountId, + chainId: caipChainId, + amount: result.balance.amount, + }); + + for (const subscription of this.#activeSubscriptions.values()) { + subscription.onAssetsUpdate(response)?.catch((error: unknown) => { + log('Failed to report staked balance update', { error }); + }); + } + } + + /** + * Fetch staked balances for all accounts on supported chains. + * + * @param request - The data request with accounts and chains. + * @returns DataResponse with staked balance data. + */ + async fetch(request: DataRequest): Promise { + if (!this.#enabled) { + return {}; + } + const response: DataResponse = {}; + const activeChainsSet = new Set(this.state.activeChains); + + const chainsToFetch = request.chainIds.filter((chainId) => + activeChainsSet.has(chainId), + ); + + if (chainsToFetch.length === 0) { + return response; + } + + const balances: Record> = {}; + + for (const { + account, + supportedChains: accountChains, + } of request.accountsWithSupportedChains) { + const chains = chainsToFetch.filter((chain) => + accountChains.includes(chain), + ); + + for (const chainId of chains) { + try { + const hexChainId = caipChainIdToHex(chainId); + const contractAddress = getStakingContractAddress(hexChainId); + if (!contractAddress) { + continue; + } + + const input: StakedBalancePollingInput = { + chainId: hexChainId, + accountId: account.id, + accountAddress: account.address as Hex, + }; + + const result = + await this.#stakedBalanceFetcher.fetchStakedBalance(input); + + // Include zero amounts so merged updates clear prior non-zero state. + balances[account.id] ??= {}; + const assetId = stakedAssetId(chainId, contractAddress); + balances[account.id][assetId] = { amount: result.amount }; + } catch (error) { + log('Failed to fetch staked balance', { + chainId, + accountId: account.id, + error, + }); + } + } + } + + if (Object.keys(balances).length > 0) { + response.assetsBalance = balances; + // Add metadata for each staked asset ID present in balances + const assetIds = new Set(); + for (const accountBalances of Object.values(balances)) { + for (const assetId of Object.keys(accountBalances)) { + assetIds.add(assetId as Caip19AssetId); + } + } + response.assetsInfo = {}; + for (const assetId of assetIds) { + response.assetsInfo[assetId] = STAKED_ETH_METADATA; + } + } + + return response; + } + + /** + * Assets middleware for the fetch pipeline. + * Fetches staked balances and merges them into the response, then passes + * all chains to the next middleware (staked balance doesn't claim chains). + * + * @returns The middleware function for the assets pipeline. + */ + get assetsMiddleware(): Middleware { + return async (context, next) => { + if (!this.#enabled) { + return next(context); + } + const { request } = context; + + if (!request.dataTypes.includes('balance')) { + return next(context); + } + + if (request.chainIds.length === 0) { + return next(context); + } + + try { + const fetchResponse = await this.fetch(request); + + if (fetchResponse.assetsInfo) { + context.response.assetsInfo ??= {}; + Object.assign(context.response.assetsInfo, fetchResponse.assetsInfo); + } + if (fetchResponse.assetsBalance) { + context.response.assetsBalance ??= {}; + for (const [accountId, accountBalances] of Object.entries( + fetchResponse.assetsBalance, + )) { + context.response.assetsBalance[accountId] = { + ...context.response.assetsBalance[accountId], + ...accountBalances, + }; + } + } + } catch (error) { + log('Middleware fetch failed', { error }); + } + + // Pass all chains through (staked balance doesn't claim chains) + return next(context); + }; + } + + /** + * Subscribe to staked balance updates with polling. + * Starts polling via StakedBalanceFetcher for each account/chain combination. + * + * @param subscriptionRequest - The subscription request details. + */ + async subscribe(subscriptionRequest: SubscriptionRequest): Promise { + const { request, subscriptionId, isUpdate } = subscriptionRequest; + + const activeChainsSet = new Set(this.state.activeChains); + const chainsToSubscribe = request.chainIds.filter((chainId) => + activeChainsSet.has(chainId), + ); + + log('Subscribe requested', { + subscriptionId, + isUpdate, + chainsToSubscribe, + }); + + if (chainsToSubscribe.length === 0) { + log('No staking chains to subscribe'); + return; + } + + // Handle subscription update - restart polling for new chains + if (isUpdate) { + const existing = this.#activeSubscriptions.get(subscriptionId); + if (existing) { + log('Updating existing subscription - restarting polling', { + subscriptionId, + }); + } + } + + // Clean up existing subscription (stops old polling) + await this.unsubscribe(subscriptionId); + + // Build subscription data first so it is available when the first poll runs + const accountsWithSupportedChains: AccountWithSupportedChains[] = + request.accountsWithSupportedChains + .map(({ account, supportedChains }) => ({ + account, + supportedChains: chainsToSubscribe.filter((chain) => + supportedChains.includes(chain), + ), + })) + .filter(({ supportedChains }) => supportedChains.length > 0); + + const accounts = accountsWithSupportedChains.map((entry) => entry.account); + const pollingTokens: string[] = []; + + // Store subscription before startPolling so first poll (setTimeout 0) has the callback + this.#activeSubscriptions.set(subscriptionId, { + pollingTokens, + chains: chainsToSubscribe, + accounts, + accountsWithSupportedChains, + onAssetsUpdate: subscriptionRequest.onAssetsUpdate, + }); + + this.activeSubscriptions.set(subscriptionId, { + cleanup: () => { + for (const token of pollingTokens) { + this.#stakedBalanceFetcher.stopPollingByPollingToken(token); + } + this.#activeSubscriptions.delete(subscriptionId); + }, + chains: chainsToSubscribe, + request, + onAssetsUpdate: subscriptionRequest.onAssetsUpdate, + }); + + // Start polling for each account/chain (first poll runs on next tick) + for (const { + account, + supportedChains: accountChains, + } of request.accountsWithSupportedChains) { + const chainsForAccount = chainsToSubscribe.filter((chain) => + accountChains.includes(chain), + ); + + for (const chainId of chainsForAccount) { + const hexChainId = caipChainIdToHex(chainId); + + const input: StakedBalancePollingInput = { + chainId: hexChainId, + accountId: account.id, + accountAddress: account.address as Hex, + }; + + const pollingToken = this.#stakedBalanceFetcher.startPolling(input); + pollingTokens.push(pollingToken); + } + } + + // Immediate initial fetch so state is updated without waiting for first poll + try { + const initialRequest: DataRequest = { + accountsWithSupportedChains, + chainIds: chainsToSubscribe, + dataTypes: ['balance'], + }; + const initialResponse = await this.fetch(initialRequest); + if ( + initialResponse.assetsBalance && + Object.keys(initialResponse.assetsBalance).length > 0 + ) { + subscriptionRequest + .onAssetsUpdate?.(initialResponse) + ?.catch((error) => { + log('Initial staked balance update failed', { error }); + }); + } + } catch (error) { + log('Initial staked balance fetch failed', { error }); + } + + log('Subscription SUCCESS', { + subscriptionId, + chains: chainsToSubscribe, + pollingCount: pollingTokens.length, + }); + } + + /** + * Unsubscribe from staked balance updates and stop polling. + * + * @param subscriptionId - The subscription ID to unsubscribe. + */ + async unsubscribe(subscriptionId: string): Promise { + const subscription = this.#activeSubscriptions.get(subscriptionId); + if (subscription) { + for (const token of subscription.pollingTokens) { + this.#stakedBalanceFetcher.stopPollingByPollingToken(token); + } + this.#activeSubscriptions.delete(subscriptionId); + } + + await super.unsubscribe(subscriptionId); + } + + /** + * Destroy the data source and clean up all resources. + */ + destroy(): void { + for (const subscription of this.#activeSubscriptions.values()) { + for (const token of subscription.pollingTokens) { + this.#stakedBalanceFetcher.stopPollingByPollingToken(token); + } + } + this.#activeSubscriptions.clear(); + this.#providerCache.clear(); + super.destroy(); + } +} diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts index 895e88b7a1a..e50fda87eec 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.test.ts @@ -97,7 +97,7 @@ function createMiddlewareContext(overrides?: Partial): Context { request: createDataRequest(), response: {}, getAssetsState: jest.fn().mockReturnValue({ - assetsMetadata: {}, + assetsInfo: {}, }), ...overrides, }; @@ -200,7 +200,7 @@ describe('TokenDataSource', () => { includeRwaData: true, }, ); - expect(context.response.assetsMetadata?.[MOCK_TOKEN_ASSET]).toStrictEqual({ + expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toStrictEqual({ type: 'erc20', name: 'Test Token', symbol: 'TEST', @@ -231,7 +231,7 @@ describe('TokenDataSource', () => { detectedAssets: { 'mock-account-id': [MOCK_TOKEN_ASSET], }, - assetsMetadata: { + assetsInfo: { [MOCK_TOKEN_ASSET]: { type: 'erc20', name: 'Existing', @@ -262,7 +262,7 @@ describe('TokenDataSource', () => { }, }, getAssetsState: jest.fn().mockReturnValue({ - assetsMetadata: { + assetsInfo: { [MOCK_TOKEN_ASSET]: { type: 'erc20', name: 'State Token', @@ -292,7 +292,7 @@ describe('TokenDataSource', () => { detectedAssets: { 'mock-account-id': [MOCK_TOKEN_ASSET], }, - assetsMetadata: { + assetsInfo: { [MOCK_TOKEN_ASSET]: { type: 'erc20', name: 'Existing', @@ -439,7 +439,7 @@ describe('TokenDataSource', () => { await controller.assetsMiddleware(context, next); - expect(context.response.assetsMetadata?.[MOCK_NATIVE_ASSET]?.type).toBe( + expect(context.response.assetsInfo?.[MOCK_NATIVE_ASSET]?.type).toBe( 'native', ); }); @@ -467,7 +467,7 @@ describe('TokenDataSource', () => { await controller.assetsMiddleware(context, next); - expect(context.response.assetsMetadata?.[MOCK_SPL_ASSET]?.type).toBe('spl'); + expect(context.response.assetsInfo?.[MOCK_SPL_ASSET]?.type).toBe('spl'); }); it('middleware merges metadata into existing response', async () => { @@ -482,7 +482,7 @@ describe('TokenDataSource', () => { const next = jest.fn().mockResolvedValue(undefined); const context = createMiddlewareContext({ response: { - assetsMetadata: { + assetsInfo: { [anotherAsset]: { type: 'erc20', name: 'DAI', @@ -499,8 +499,8 @@ describe('TokenDataSource', () => { await controller.assetsMiddleware(context, next); - expect(context.response.assetsMetadata?.[anotherAsset]).toBeDefined(); - expect(context.response.assetsMetadata?.[MOCK_TOKEN_ASSET]).toBeDefined(); + expect(context.response.assetsInfo?.[anotherAsset]).toBeDefined(); + expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toBeDefined(); }); it('middleware handles multiple detected assets from multiple accounts', async () => { @@ -540,8 +540,8 @@ describe('TokenDataSource', () => { includeRwaData: true, }, ); - expect(context.response.assetsMetadata?.[MOCK_TOKEN_ASSET]).toBeDefined(); - expect(context.response.assetsMetadata?.[secondAsset]).toBeDefined(); + expect(context.response.assetsInfo?.[MOCK_TOKEN_ASSET]).toBeDefined(); + expect(context.response.assetsInfo?.[secondAsset]).toBeDefined(); }); it('middleware deduplicates assets across accounts', async () => { diff --git a/packages/assets-controller/src/data-sources/TokenDataSource.ts b/packages/assets-controller/src/data-sources/TokenDataSource.ts index 836e8ef9f31..ee71fe81f78 100644 --- a/packages/assets-controller/src/data-sources/TokenDataSource.ts +++ b/packages/assets-controller/src/data-sources/TokenDataSource.ts @@ -3,6 +3,7 @@ import { ApiPlatformClient } from '@metamask/core-backend'; import { parseCaipAssetType } from '@metamask/utils'; import type { CaipAssetType } from '@metamask/utils'; +import { isStakingContractAssetId } from './evm-rpc-services'; import { projectLogger, createModuleLogger } from '../logger'; import { forDataTypes } from '../types'; import type { @@ -109,6 +110,10 @@ function transformV3AssetResponseToMetadata( export class TokenDataSource { readonly name = CONTROLLER_NAME; + getName(): string { + return this.name; + } + /** ApiPlatformClient for cached API calls */ readonly #apiClient: ApiPlatformClient; @@ -185,13 +190,13 @@ export class TokenDataSource { return next(ctx); } - const { assetsMetadata: stateMetadata } = ctx.getAssetsState(); + const { assetsInfo: stateMetadata } = ctx.getAssetsState(); const assetIdsNeedingMetadata = new Set(); for (const detectedIds of Object.values(response.detectedAssets)) { for (const assetId of detectedIds) { // Skip if response already has metadata with image - const responseMetadata = response.assetsMetadata?.[assetId]; + const responseMetadata = response.assetsInfo?.[assetId]; if (responseMetadata?.image) { continue; } @@ -202,6 +207,11 @@ export class TokenDataSource { continue; } + // Skip staking contracts; we use built-in metadata and do not fetch from the tokens API + if (isStakingContractAssetId(assetId)) { + continue; + } + assetIdsNeedingMetadata.add(assetId); } } @@ -235,12 +245,14 @@ export class TokenDataSource { }, ); - response.assetsMetadata ??= {}; + response.assetsInfo ??= {}; for (const assetData of metadataResponse) { const caipAssetId = assetData.assetId as Caip19AssetId; - response.assetsMetadata[caipAssetId] = - transformV3AssetResponseToMetadata(assetData.assetId, assetData); + response.assetsInfo[caipAssetId] = transformV3AssetResponseToMetadata( + assetData.assetId, + assetData, + ); } } catch (error) { log('Failed to fetch metadata', { error }); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts index 0110f25dc13..e47693f7299 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/index.ts @@ -14,9 +14,16 @@ export { MulticallClient, type MulticallClientConfig } from './clients'; export { BalanceFetcher, TokenDetector, + StakedBalanceFetcher, + getSupportedStakingChainIds, + getStakingContractAddress, + isStakingContractAssetId, type BalancePollingInput, type DetectionPollingInput, + type StakedBalancePollingInput, + type StakedBalanceFetchResult, type OnBalanceUpdateCallback, type OnDetectionUpdateCallback, + type OnStakedBalanceUpdateCallback, } from './services'; export { divideIntoBatches, reduceInBatchesSerially } from './utils'; diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/StakedBalanceFetcher.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/StakedBalanceFetcher.test.ts new file mode 100644 index 00000000000..d63b9ba8070 --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/StakedBalanceFetcher.test.ts @@ -0,0 +1,245 @@ +import { defaultAbiCoder } from '@ethersproject/abi'; +import type { Web3Provider } from '@ethersproject/providers'; + +import type { + StakedBalanceFetcherConfig, + StakedBalancePollingInput, +} from './StakedBalanceFetcher'; +import { + StakedBalanceFetcher, + isStakingContractAssetId, +} from './StakedBalanceFetcher'; + +const TEST_ADDRESS = '0x9bed78535d6a03a955f1504aadba974d9a29e292'; +const MAINNET_CHAIN_ID = '0x1'; +const INPUT: StakedBalancePollingInput = { + chainId: MAINNET_CHAIN_ID, + accountId: 'test-account-id', + accountAddress: TEST_ADDRESS as StakedBalancePollingInput['accountAddress'], +}; + +/** + * Creates a mock Web3Provider that returns the specified shares and assets when called. + * + * @param options - The options for the mock provider. + * @param options.sharesWei - The shares to return when the provider is called. + * @param options.assetsWei - The assets to return when the provider is called. + * @returns A mock Web3Provider. + */ +function createMockProvider(options: { + sharesWei?: string; + assetsWei?: string; +}): jest.Mocked { + const { sharesWei = '0', assetsWei = '0' } = options; + let callCount = 0; + + const mockCall = jest.fn().mockImplementation(async () => { + callCount += 1; + if (callCount === 1) { + return defaultAbiCoder.encode(['uint256'], [sharesWei]); + } + return defaultAbiCoder.encode(['uint256'], [assetsWei]); + }); + + return { + call: mockCall, + } as unknown as jest.Mocked; +} + +function createFetcher( + config?: StakedBalanceFetcherConfig, +): StakedBalanceFetcher { + return new StakedBalanceFetcher(config); +} + +describe('isStakingContractAssetId', () => { + it('returns true for mainnet staking contract asset ID', () => { + expect( + isStakingContractAssetId( + 'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df', + ), + ).toBe(true); + expect( + isStakingContractAssetId( + 'eip155:1/erc20:0x4FEF9D741011476750A243aC70b9789a63dd47Df', + ), + ).toBe(true); + }); + + it('returns true for Hoodi staking contract asset ID', () => { + expect( + isStakingContractAssetId( + 'eip155:560048/erc20:0xe96ac18cfe5a7af8fe1fe7bc37ff110d88bc67ff', + ), + ).toBe(true); + }); + + it('returns false for other ERC20 asset IDs', () => { + expect( + isStakingContractAssetId( + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ), + ).toBe(false); + expect(isStakingContractAssetId('eip155:1/slip44:60')).toBe(false); + }); + + it('returns false for malformed asset IDs', () => { + expect(isStakingContractAssetId('eip155:1')).toBe(false); + expect(isStakingContractAssetId('')).toBe(false); + }); +}); + +describe('StakedBalanceFetcher', () => { + describe('constructor', () => { + it('accepts empty config', () => { + expect(() => createFetcher()).not.toThrow(); + }); + + it('accepts config with getNetworkProvider and pollingInterval', () => { + const provider = createMockProvider({}); + expect(() => + createFetcher({ + getNetworkProvider: () => provider, + pollingInterval: 60_000, + }), + ).not.toThrow(); + }); + }); + + describe('fetchStakedBalance', () => { + it('returns amount "0" when chain has no staking contract', async () => { + const provider = createMockProvider({ sharesWei: '100' }); + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + + const result = await fetcher.fetchStakedBalance({ + ...INPUT, + chainId: '0x999' as StakedBalancePollingInput['chainId'], + }); + + expect(result).toStrictEqual({ amount: '0' }); + expect(provider.call).not.toHaveBeenCalled(); + }); + + it('returns amount "0" when getShares returns zero', async () => { + const provider = createMockProvider({ sharesWei: '0' }); + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + + const result = await fetcher.fetchStakedBalance(INPUT); + + expect(result).toStrictEqual({ amount: '0' }); + expect(provider.call).toHaveBeenCalledTimes(1); + }); + + it('returns human-readable amount when shares and assets are non-zero', async () => { + const provider = createMockProvider({ + sharesWei: '1000000000000000000', + assetsWei: '1500000000000000000', // 1.5 ETH + }); + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + + const result = await fetcher.fetchStakedBalance(INPUT); + + expect(result).toStrictEqual({ amount: '1.5' }); + expect(provider.call).toHaveBeenCalledTimes(2); + }); + + it('throws on provider or contract error so callers do not persist false zero', async () => { + const provider = createMockProvider({ + sharesWei: '1000000000000000000', + assetsWei: '1500000000000000000', + }); + (provider.call as jest.Mock).mockRejectedValueOnce( + new Error('RPC error'), + ); + + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + + await expect(fetcher.fetchStakedBalance(INPUT)).rejects.toThrow( + 'RPC error', + ); + }); + + it('throws when getNetworkProvider is not set', async () => { + const fetcher = createFetcher(); + + await expect(fetcher.fetchStakedBalance(INPUT)).rejects.toThrow( + 'no provider available', + ); + }); + + it('throws when getNetworkProvider returns undefined', async () => { + const fetcher = createFetcher({ + getNetworkProvider: () => undefined, + }); + + await expect(fetcher.fetchStakedBalance(INPUT)).rejects.toThrow( + 'no provider available', + ); + }); + + it('works with CAIP-2 chain ID (eip155:1)', async () => { + const provider = createMockProvider({ + sharesWei: '0', + }); + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + + const result = await fetcher.fetchStakedBalance({ + ...INPUT, + chainId: 'eip155:1' as StakedBalancePollingInput['chainId'], + }); + + expect(result).toStrictEqual({ amount: '0' }); + expect(provider.call).toHaveBeenCalledTimes(1); + }); + + it('returns whole number when assets have no fractional part', async () => { + const provider = createMockProvider({ + sharesWei: '1', + assetsWei: '2000000000000000000', // 2 ETH + }); + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + + const result = await fetcher.fetchStakedBalance(INPUT); + + expect(result).toStrictEqual({ amount: '2' }); + }); + }); + + describe('_executePoll', () => { + it('calls fetchStakedBalance with input', async () => { + const provider = createMockProvider({ sharesWei: '0' }); + const fetcher = createFetcher({ + getNetworkProvider: () => provider, + }); + const fetchSpy = jest.spyOn(fetcher, 'fetchStakedBalance'); + + await fetcher._executePoll(INPUT); + + expect(fetchSpy).toHaveBeenCalledWith(INPUT); + }); + + it('does not call the update callback when fetchStakedBalance throws', async () => { + const fetcher = createFetcher({ + getNetworkProvider: () => undefined, + }); + const callback = jest.fn(); + fetcher.setOnStakedBalanceUpdate(callback); + + await fetcher._executePoll(INPUT); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/StakedBalanceFetcher.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/StakedBalanceFetcher.ts new file mode 100644 index 00000000000..035c160b18f --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/StakedBalanceFetcher.ts @@ -0,0 +1,190 @@ +import { Interface } from '@ethersproject/abi'; +import { Web3Provider } from '@ethersproject/providers'; +import { StaticIntervalPollingControllerOnly } from '@metamask/polling-controller'; + +import type { Address, AccountId, ChainId } from '../types'; +import { + getStakingContractAddress, + getSupportedStakingChainIds, + isStakingContractAssetId, + weiToHumanReadable, +} from '../utils'; + +export { + getStakingContractAddress, + getSupportedStakingChainIds, + isStakingContractAssetId, +}; + +export type StakedBalancePollingInput = { + /** Chain ID (hex format, e.g. 0x1) */ + chainId: ChainId; + /** Account ID */ + accountId: AccountId; + /** Account address */ + accountAddress: Address; +}; + +/** Human-readable staked balance (e.g. "1.5" for 1.5 ETH). */ +export type StakedBalance = { + amount: string; +}; + +/** Result reported via the update callback. */ +export type StakedBalanceFetchResult = { + /** Account ID (UUID). */ + accountId: AccountId; + /** Hex chain ID. */ + chainId: ChainId; + /** Human-readable staked balance. */ + balance: StakedBalance; +}; + +/** + * Callback type for staked balance updates. + */ +export type OnStakedBalanceUpdateCallback = ( + result: StakedBalanceFetchResult, +) => void; + +/** Staking contract ABI: getShares(account) and convertToAssets(shares). */ +const STAKING_CONTRACT_ABI = [ + { + inputs: [{ internalType: 'address', name: 'account', type: 'address' }], + name: 'getShares', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + name: 'convertToAssets', + outputs: [{ internalType: 'uint256', name: 'assets', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +]; + +export const STAKING_INTERFACE = new Interface(STAKING_CONTRACT_ABI); + +const STAKING_DECIMALS = 18; + +export type StakedBalanceFetcherConfig = { + /** Polling interval in ms (default: 180s) */ + pollingInterval?: number; + /** Returns the network provider for the given chain. Required for fetchStakedBalance. */ + getNetworkProvider?: (chainId: ChainId) => Web3Provider | undefined; +}; + +const DEFAULT_STAKED_BALANCE_INTERVAL = 180_000; // 3 minutes + +export class StakedBalanceFetcher extends StaticIntervalPollingControllerOnly() { + readonly #providerGetter?: (chainId: ChainId) => Web3Provider | undefined; + + #onStakedBalanceUpdate: OnStakedBalanceUpdateCallback | undefined; + + constructor(config?: StakedBalanceFetcherConfig) { + super(); + this.#providerGetter = config?.getNetworkProvider; + + this.setIntervalLength( + config?.pollingInterval ?? DEFAULT_STAKED_BALANCE_INTERVAL, + ); + } + + /** + * Register a callback that is invoked after every successful poll with + * the staked balance (including zero). Zero is reported so that merged + * updates can clear prior non-zero state. + * + * @param callback - The callback to invoke. + */ + setOnStakedBalanceUpdate(callback: OnStakedBalanceUpdateCallback): void { + this.#onStakedBalanceUpdate = callback; + } + + async _executePoll(input: StakedBalancePollingInput): Promise { + let result: StakedBalance; + try { + result = await this.fetchStakedBalance(input); + } catch { + // Do not push an update on provider/RPC failure; otherwise we would + // overwrite existing non-zero staked balances with zero in state. + return; + } + + if (this.#onStakedBalanceUpdate) { + this.#onStakedBalanceUpdate({ + accountId: input.accountId, + chainId: input.chainId, + balance: result, + }); + } + } + + /** + * Fetches the staked balance for an account on a chain using the same + * staking contract as AccountTrackerController (getShares then convertToAssets). + * Returns a human-readable amount string (e.g. "1.5" for 1.5 ETH). + * Throws when no provider is available or when the RPC/contract call fails, so + * callers do not persist a false zero and overwrite existing balances. + * + * @param input - Chain, account ID, and address to query. + * @returns Human-readable staked balance (amount string). + * @throws When provider is missing or when getShares/convertToAssets fails. + */ + async fetchStakedBalance( + input: StakedBalancePollingInput, + ): Promise { + const { chainId, accountAddress } = input; + const provider = this.#providerGetter?.(chainId); + if (!provider) { + throw new Error('StakedBalanceFetcher: no provider available for chain'); + } + const contractAddress = getStakingContractAddress(chainId); + + if (!contractAddress) { + return { amount: '0' }; + } + + try { + const sharesCalldata = STAKING_INTERFACE.encodeFunctionData('getShares', [ + accountAddress, + ]); + const sharesResult = await provider.call({ + to: contractAddress, + data: sharesCalldata, + }); + const sharesRaw = STAKING_INTERFACE.decodeFunctionResult( + 'getShares', + sharesResult, + )[0]; + const sharesBigNum = BigInt(sharesRaw.toString()); + + if (sharesBigNum === 0n) { + return { amount: '0' }; + } + + const assetsCalldata = STAKING_INTERFACE.encodeFunctionData( + 'convertToAssets', + [sharesBigNum], + ); + const assetsResult = await provider.call({ + to: contractAddress, + data: assetsCalldata, + }); + const assetsRaw = STAKING_INTERFACE.decodeFunctionResult( + 'convertToAssets', + assetsResult, + )[0]; + const assetsWei = BigInt(assetsRaw.toString()); + + const amount = weiToHumanReadable(assetsWei, STAKING_DECIMALS); + return { amount }; + } catch (error) { + throw error instanceof Error + ? error + : new Error('StakedBalanceFetcher: failed to fetch staked balance'); + } + } +} diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts index 972b16914d4..7136934fd0e 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.test.ts @@ -198,7 +198,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); controller.setOnDetectionUpdate(mockCallback); @@ -243,7 +246,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { const mockCallback = jest.fn(); controller.setOnDetectionUpdate(mockCallback); @@ -261,6 +267,7 @@ describe('TokenDetector', () => { await controller._executePoll(input); expect(mockCallback).not.toHaveBeenCalled(); + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(1); }, ); }); @@ -438,7 +445,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -488,7 +498,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, true, '0'), @@ -519,7 +532,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, false), @@ -560,7 +576,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -587,6 +606,341 @@ describe('TokenDetector', () => { }); }); + describe('tokenDetectionEnabled', () => { + it('returns empty result and does not call batchBalanceOf when tokenDetectionEnabled is false in config', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { tokenDetectionEnabled: () => false }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + ); + + expect(result).toStrictEqual({ + chainId: MAINNET_CHAIN_ID, + accountId: TEST_ACCOUNT_ID, + accountAddress: TEST_ACCOUNT, + detectedAssets: [], + detectedBalances: [], + zeroBalanceAddresses: [], + failedAddresses: [], + timestamp: 1700000000000, + }); + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }, + ); + }); + + it('runs detection when tokenDetectionEnabled is true in config', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + TEST_TOKEN_1, + TEST_ACCOUNT, + true, + '1000000', + ), + ]); + + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + ); + + expect(result.detectedAssets).toHaveLength(1); + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('options.tokenDetectionEnabled overrides config when true', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { tokenDetectionEnabled: () => false }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + TEST_TOKEN_1, + TEST_ACCOUNT, + true, + '1000000', + ), + ]); + + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + { tokenDetectionEnabled: true }, + ); + + expect(result.detectedAssets).toHaveLength(1); + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('options.tokenDetectionEnabled overrides config when false', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + { tokenDetectionEnabled: false }, + ); + + expect(result.detectedAssets).toStrictEqual([]); + expect(result.detectedBalances).toStrictEqual([]); + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }, + ); + }); + + it('_executePoll does not call onDetectionUpdate when tokenDetectionEnabled is false in config', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { tokenDetectionEnabled: () => false }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + const mockCallback = jest.fn(); + controller.setOnDetectionUpdate(mockCallback); + + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + TEST_TOKEN_1, + TEST_ACCOUNT, + true, + '1000000000', + ), + ]); + + const input: DetectionPollingInput = { + chainId: MAINNET_CHAIN_ID, + accountId: TEST_ACCOUNT_ID, + accountAddress: TEST_ACCOUNT, + }; + + await controller._executePoll(input); + + expect(mockCallback).not.toHaveBeenCalled(); + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }, + ); + }); + + it('returns empty result and does not call batchBalanceOf when useExternalService is false in config', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { + tokenDetectionEnabled: () => true, + useExternalService: () => false, + }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + ); + + expect(result.detectedAssets).toStrictEqual([]); + expect(result.detectedBalances).toStrictEqual([]); + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }, + ); + }); + + it('runs detection when both tokenDetectionEnabled and useExternalService are true', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { + tokenDetectionEnabled: () => true, + useExternalService: () => true, + }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + TEST_TOKEN_1, + TEST_ACCOUNT, + true, + '1000000', + ), + ]); + + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + ); + + expect(result.detectedAssets).toHaveLength(1); + expect(mockMulticallClient.batchBalanceOf).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('options.useExternalService overrides config when false', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { + tokenDetectionEnabled: () => true, + useExternalService: () => true, + }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + const result = await controller.detectTokens( + MAINNET_CHAIN_ID, + TEST_ACCOUNT_ID, + TEST_ACCOUNT, + { useExternalService: false }, + ); + + expect(result.detectedAssets).toStrictEqual([]); + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }, + ); + }); + + it('_executePoll does not call onDetectionUpdate when useExternalService is false in config', async () => { + const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ + { + address: TEST_TOKEN_1, + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }, + ]); + + await withController( + { + config: { + tokenDetectionEnabled: () => true, + useExternalService: () => false, + }, + tokenListState: mockState, + }, + async ({ controller, mockMulticallClient }) => { + const mockCallback = jest.fn(); + controller.setOnDetectionUpdate(mockCallback); + + mockMulticallClient.batchBalanceOf.mockResolvedValue([ + createMockBalanceResponse( + TEST_TOKEN_1, + TEST_ACCOUNT, + true, + '1000000000', + ), + ]); + + const input: DetectionPollingInput = { + chainId: MAINNET_CHAIN_ID, + accountId: TEST_ACCOUNT_ID, + accountAddress: TEST_ACCOUNT, + }; + + await controller._executePoll(input); + + expect(mockCallback).not.toHaveBeenCalled(); + expect(mockMulticallClient.batchBalanceOf).not.toHaveBeenCalled(); + }, + ); + }); + }); + describe('asset creation', () => { it('creates correct CAIP-19 asset ID for mainnet', async () => { const mockState = createMockTokenListState(MAINNET_CHAIN_ID, [ @@ -599,7 +953,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -634,7 +991,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -671,7 +1031,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -706,7 +1069,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -749,7 +1115,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse(TEST_TOKEN_1, TEST_ACCOUNT, true, '100'), @@ -784,7 +1153,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf .mockResolvedValueOnce([ @@ -843,7 +1215,10 @@ describe('TokenDetector', () => { }; await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( @@ -879,7 +1254,10 @@ describe('TokenDetector', () => { ]); await withController( - { tokenListState: mockState }, + { + config: { tokenDetectionEnabled: () => true }, + tokenListState: mockState, + }, async ({ controller, mockMulticallClient }) => { mockMulticallClient.batchBalanceOf.mockResolvedValue([ createMockBalanceResponse( diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts index 0fc768286a1..b79bc171fa9 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/TokenDetector.ts @@ -27,6 +27,10 @@ export type TokenDetectorMessenger = { }; export type TokenDetectorConfig = { + /** Function returning whether token detection is enabled (avoids stale value) */ + tokenDetectionEnabled?: () => boolean; + /** Function returning whether external services are allowed (avoids stale value; default: () => true) */ + useExternalService?: () => boolean; defaultBatchSize?: number; defaultTimeoutMs?: number; /** Polling interval in ms (default: 3 minutes) */ @@ -72,6 +76,9 @@ export class TokenDetector extends StaticIntervalPollingControllerOnly true), + useExternalService: config?.useExternalService ?? ((): boolean => true), defaultBatchSize: config?.defaultBatchSize ?? 300, defaultTimeoutMs: config?.defaultTimeoutMs ?? 30000, }; @@ -150,6 +157,22 @@ export class TokenDetector extends StaticIntervalPollingControllerOnly { + const tokenDetectionEnabled = + options?.tokenDetectionEnabled ?? this.#config.tokenDetectionEnabled(); + const useExternalService = + options?.useExternalService ?? this.#config.useExternalService(); + if (!tokenDetectionEnabled || !useExternalService) { + return { + chainId, + accountId, + accountAddress, + detectedAssets: [], + detectedBalances: [], + zeroBalanceAddresses: [], + failedAddresses: [], + timestamp: Date.now(), + }; + } const batchSize = options?.batchSize ?? this.#config.defaultBatchSize; const timestamp = Date.now(); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts index c3231e3f58e..4c8d7f95a0f 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/services/index.ts @@ -12,3 +12,13 @@ export { type BalancePollingInput, type OnBalanceUpdateCallback, } from './BalanceFetcher'; +export { + StakedBalanceFetcher, + getSupportedStakingChainIds, + getStakingContractAddress, + isStakingContractAssetId, + type StakedBalanceFetcherConfig, + type StakedBalancePollingInput, + type StakedBalanceFetchResult, + type OnStakedBalanceUpdateCallback, +} from './StakedBalanceFetcher'; diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts index afea708def9..21c8f6c0c01 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/types/services.ts @@ -27,6 +27,10 @@ export type TokenDetectionResult = { * Token detection options. */ export type TokenDetectionOptions = { + /** Whether token detection is enabled */ + tokenDetectionEnabled?: boolean; + /** Whether external services (e.g. token list API) are allowed; detection stops when false */ + useExternalService?: boolean; /** Maximum number of tokens to check per batch */ batchSize?: number; /** Timeout for detection in milliseconds */ diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts index 2cc7268b681..e1d4c026742 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/types/state.ts @@ -1,11 +1,11 @@ -import type { Address, ChainId } from './core'; +import type { ChainId } from './core'; /** * Single token entry from token list. */ export type TokenListEntry = { /** Contract address */ - address: Address; + address: string; /** Token symbol */ symbol: string; /** Token name */ @@ -27,7 +27,7 @@ export type TokenChainsCacheEntry = { /** Timestamp when the cache was last updated */ timestamp: number; /** Token list data: address -> TokenListEntry */ - data: Record; + data: Record; }; /** diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/utils/index.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/index.ts index 84e9c137f94..8a9129363d1 100644 --- a/packages/assets-controller/src/data-sources/evm-rpc-services/utils/index.ts +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/index.ts @@ -1 +1,8 @@ export { divideIntoBatches, reduceInBatchesSerially } from './batch'; +export { chainIdToHex, weiToHumanReadable } from './parsing'; +export { + getStakingContractAddress, + getSupportedStakingChainIds, + isStakingContractAssetId, + STAKING_CONTRACT_ADDRESS_BY_CHAINID, +} from './staking-contracts'; diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/utils/parsing.test.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/parsing.test.ts new file mode 100644 index 00000000000..21a14f53671 --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/parsing.test.ts @@ -0,0 +1,58 @@ +import { chainIdToHex, weiToHumanReadable } from './parsing'; + +describe('parsing utilities', () => { + describe('weiToHumanReadable', () => { + it('converts 1 wei to "0.000000000000000001" with 18 decimals', () => { + expect(weiToHumanReadable('1', 18)).toBe('0.000000000000000001'); + }); + + it('converts 1.5 ETH (18 decimals) to "1.5"', () => { + expect(weiToHumanReadable('1500000000000000000', 18)).toBe('1.5'); + }); + + it('converts whole number wei to integer string', () => { + expect(weiToHumanReadable('1000000000000000000', 18)).toBe('1'); + }); + + it('trims trailing zeros in fractional part', () => { + expect(weiToHumanReadable('1500000000000000000', 18)).toBe('1.5'); + expect(weiToHumanReadable('1005000000000000000', 18)).toBe('1.005'); + }); + + it('returns "0" for zero wei', () => { + expect(weiToHumanReadable('0', 18)).toBe('0'); + }); + + it('handles small decimals (e.g. 6)', () => { + expect(weiToHumanReadable('1500000', 6)).toBe('1.5'); + }); + + it('handles single digit wei below one unit', () => { + expect(weiToHumanReadable('5', 18)).toBe('0.000000000000000005'); + }); + + it('accepts bigint and converts to human-readable', () => { + expect(weiToHumanReadable(1500000000000000000n, 18)).toBe('1.5'); + expect(weiToHumanReadable(0n, 18)).toBe('0'); + }); + }); + + describe('chainIdToHex', () => { + it('converts CAIP-2 eip155:1 to 0x1', () => { + expect(chainIdToHex('eip155:1')).toBe('0x1'); + }); + + it('converts CAIP-2 eip155:137 to 0x89', () => { + expect(chainIdToHex('eip155:137')).toBe('0x89'); + }); + + it('returns hex chain ID unchanged', () => { + expect(chainIdToHex('0x1')).toBe('0x1'); + expect(chainIdToHex('0x88bb0')).toBe('0x88bb0'); + }); + + it('converts CAIP-2 eip155:560048 to 0x88bb0', () => { + expect(chainIdToHex('eip155:560048')).toBe('0x88bb0'); + }); + }); +}); diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/utils/parsing.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/parsing.ts new file mode 100644 index 00000000000..7a2a5c32c65 --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/parsing.ts @@ -0,0 +1,51 @@ +import { + isCaipChainId, + isStrictHexString, + numberToHex, + parseCaipChainId, +} from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; +import BigNumberJS from 'bignumber.js'; + +import type { ChainId } from '../types'; + +/** + * Convert wei to human-readable amount with decimals, trim trailing zeros. + * Uses BigNumber for precision. + * + * Note: `.shiftedBy(-decimals).toFixed()` (no args) rounds to DECIMAL_PLACES (0 + * by default in bignumber.js), producing integers (e.g. 1.5 ETH → "2"). + * We use `.toFixed(decimals)` to preserve fractional precision, then trim + * trailing zeros so the result is e.g. "1.5" instead of "1.500000000000000000". + * + * @param wei - Balance in wei as bigint or decimal string. + * @param decimals - Token decimals (e.g. 18). + * @returns Human-readable amount string (e.g. "1.5"). + */ +export function weiToHumanReadable( + wei: string | bigint, + decimals: number, +): string { + const weiStr = typeof wei === 'bigint' ? wei.toString() : wei; + const fixed = new BigNumberJS(weiStr).shiftedBy(-decimals).toFixed(decimals); + const trimmed = fixed.replace(/\.?0+$/u, ''); + return trimmed === '' ? '0' : trimmed; +} + +/** + * Normalize chain ID to hex for contract lookup (e.g. eip155:1 -> 0x1). + * Uses @metamask/utils for CAIP parsing. + * + * @param chainId - CAIP-2 or hex chain ID. + * @returns Hex chain ID for contract map lookup. + */ +export function chainIdToHex(chainId: ChainId | CaipChainId): Hex { + if (isStrictHexString(chainId)) { + return chainId; + } + if (isCaipChainId(chainId)) { + const { reference } = parseCaipChainId(chainId); + return numberToHex(parseInt(reference, 10)); + } + return chainId; +} diff --git a/packages/assets-controller/src/data-sources/evm-rpc-services/utils/staking-contracts.ts b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/staking-contracts.ts new file mode 100644 index 00000000000..e988b0693ab --- /dev/null +++ b/packages/assets-controller/src/data-sources/evm-rpc-services/utils/staking-contracts.ts @@ -0,0 +1,75 @@ +import { convertHexToDecimal } from '@metamask/controller-utils'; +import { + isCaipAssetType, + isCaipChainId, + isStrictHexString, + KnownCaipNamespace, + parseCaipAssetType, + toCaipChainId, +} from '@metamask/utils'; + +/** Staking contract addresses by CAIP-2 chain ID (e.g. "eip155:1"). */ +export const STAKING_CONTRACT_ADDRESS_BY_CHAINID: Record = { + 'eip155:1': '0x4fef9d741011476750a243ac70b9789a63dd47df', // Mainnet + 'eip155:560048': '0xe96ac18cfe5a7af8fe1fe7bc37ff110d88bc67ff', // Hoodi (0x88bb0) +}; + +/** + * Normalize chain ID to CAIP-2 for lookup (e.g. "0x1" -> "eip155:1"). + * Uses @metamask/utils for CAIP parsing. + * + * @param chainId - Hex chain ID (e.g. "0x1") or CAIP-2 (e.g. "eip155:1"). + * @returns CAIP-2 chain ID. + */ +function toCaip2ChainId(chainId: string): string { + if (isCaipChainId(chainId)) { + return chainId; + } + const reference = isStrictHexString(chainId) + ? convertHexToDecimal(chainId).toString() + : chainId; + return toCaipChainId(KnownCaipNamespace.Eip155, reference); +} + +/** + * Returns the set of CAIP-2 chain IDs that have a known staking contract. + * + * @returns Array of CAIP-2 chain IDs. + */ +export function getSupportedStakingChainIds(): string[] { + return Object.keys(STAKING_CONTRACT_ADDRESS_BY_CHAINID); +} + +/** + * Returns the staking contract address for a chain, or undefined if not supported. + * + * @param chainId - Hex chain ID (e.g. "0x1") or CAIP-2 (e.g. "eip155:1"). + * @returns Contract address (checksummed as stored) or undefined. + */ +export function getStakingContractAddress(chainId: string): string | undefined { + const caip2 = toCaip2ChainId(chainId); + return STAKING_CONTRACT_ADDRESS_BY_CHAINID[caip2]; +} + +/** + * Returns true if the CAIP-19 asset ID is for a known staking contract. + * Used to skip fetching metadata for staking contracts from the tokens API. + * Uses @metamask/utils parseCaipAssetType for CAIP-19 parsing. + * + * @param assetId - CAIP-19 asset ID (e.g. "eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df"). + * @returns True if the asset is a staking contract. + */ +export function isStakingContractAssetId(assetId: string): boolean { + if (!isCaipAssetType(assetId)) { + return false; + } + const parsed = parseCaipAssetType(assetId); + if (parsed.assetNamespace !== 'erc20') { + return false; + } + const address = parsed.assetReference.toLowerCase(); + const stakingAddress = getStakingContractAddress( + parsed.chainId, + )?.toLowerCase(); + return stakingAddress !== undefined && address === stakingAddress; +} diff --git a/packages/assets-controller/src/data-sources/index.ts b/packages/assets-controller/src/data-sources/index.ts index 94375168767..47cb7c5734c 100644 --- a/packages/assets-controller/src/data-sources/index.ts +++ b/packages/assets-controller/src/data-sources/index.ts @@ -6,6 +6,7 @@ export { export { AccountsApiDataSource, + type AccountsApiDataSourceConfig, type AccountsApiDataSourceOptions, type AccountsApiDataSourceState, type AccountsApiDataSourceAllowedActions, @@ -39,6 +40,7 @@ export { export { PriceDataSource, + type PriceDataSourceConfig, type PriceDataSourceOptions, } from './PriceDataSource'; @@ -58,3 +60,9 @@ export { type SnapDataSourceAllowedActions, type SnapDataSourceAllowedEvents, } from './SnapDataSource'; + +export { + StakedBalanceDataSource, + type StakedBalanceDataSourceConfig, + type StakedBalanceDataSourceOptions, +} from './StakedBalanceDataSource'; diff --git a/packages/assets-controller/src/index.ts b/packages/assets-controller/src/index.ts index 4818b9b75e0..be2857c1b4c 100644 --- a/packages/assets-controller/src/index.ts +++ b/packages/assets-controller/src/index.ts @@ -9,6 +9,7 @@ export type { AssetsControllerState, AssetsControllerMessenger, AssetsControllerOptions, + AssetsControllerFirstInitFetchMetaMetricsPayload, AssetsControllerGetStateAction, AssetsControllerActions, AssetsControllerStateChangeEvent, @@ -91,6 +92,7 @@ export type { DataSourceState, SubscriptionRequest } from './data-sources'; export { AccountsApiDataSource } from './data-sources'; export type { + AccountsApiDataSourceConfig, AccountsApiDataSourceOptions, AccountsApiDataSourceState, AccountsApiDataSourceAllowedActions, @@ -146,6 +148,7 @@ export { TokenDataSource, PriceDataSource } from './data-sources'; export type { TokenDataSourceOptions, TokenDataSourceAllowedActions, + PriceDataSourceConfig, PriceDataSourceOptions, } from './data-sources'; diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts index 75d6a99e3f6..be0cb9d09b2 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.test.ts @@ -57,12 +57,12 @@ function createDataRequest( function createAssetsState( metadataAssets: Caip19AssetId[] = [], ): AssetsControllerStateInternal { - const assetsMetadata: Record = {}; + const assetsInfo: Record = {}; for (const assetId of metadataAssets) { - assetsMetadata[assetId] = { name: `Asset ${assetId}` }; + assetsInfo[assetId] = { name: `Asset ${assetId}` }; } return { - assetsMetadata, + assetsInfo, assetsBalance: {}, customAssets: {}, } as AssetsControllerStateInternal; diff --git a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts index 6ad7f57f00a..af0b08122af 100644 --- a/packages/assets-controller/src/middlewares/DetectionMiddleware.ts +++ b/packages/assets-controller/src/middlewares/DetectionMiddleware.ts @@ -32,6 +32,10 @@ createModuleLogger(projectLogger, CONTROLLER_NAME); export class DetectionMiddleware { readonly name = CONTROLLER_NAME; + getName(): string { + return this.name; + } + /** * Get the middleware for detecting assets without metadata. * @@ -54,7 +58,7 @@ export class DetectionMiddleware { } // Get metadata from state - const { assetsMetadata: stateMetadata } = ctx.getAssetsState(); + const { assetsInfo: stateMetadata } = ctx.getAssetsState(); const detectedAssets: Record = {}; diff --git a/packages/assets-controller/src/selectors/balance.test.ts b/packages/assets-controller/src/selectors/balance.test.ts index 5d5fcad6fba..59a34fd8455 100644 --- a/packages/assets-controller/src/selectors/balance.test.ts +++ b/packages/assets-controller/src/selectors/balance.test.ts @@ -167,7 +167,7 @@ describe('balance selectors', () => { [assetUsdc]: { amount: '100' }, }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -210,7 +210,7 @@ describe('balance selectors', () => { [assetUsdc]: { amount: '1000' }, }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -258,7 +258,7 @@ describe('balance selectors', () => { [assetUsdc]: { amount: '100' }, }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -300,7 +300,7 @@ describe('balance selectors', () => { [assetPolygon]: { amount: '10' }, }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -335,7 +335,7 @@ describe('balance selectors', () => { [accountId1]: { [assetEth]: { amount: '1' } }, [accountId2]: { [assetEth]: { amount: '2' } }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -367,7 +367,7 @@ describe('balance selectors', () => { [accountId1]: { [assetEth]: { amount: '1' } }, [accountId2]: { [assetEth]: { amount: '99' } }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -392,7 +392,7 @@ describe('balance selectors', () => { [accountId1]: { [assetEth]: { amount: '1' } }, [accountId2]: { [assetUsdc]: { amount: '500' } }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', @@ -428,7 +428,7 @@ describe('balance selectors', () => { it('returns empty entries when state has no balance for account', () => { const state: AssetsControllerState = { assetsBalance: {}, - assetsMetadata: {}, + assetsInfo: {}, assetsPrice: {}, customAssets: {}, assetPreferences: {}, @@ -450,7 +450,7 @@ describe('balance selectors', () => { [assetPolygon]: { amount: '10' }, }, }, - assetsMetadata: { + assetsInfo: { [assetEth]: { type: 'native', symbol: 'ETH', diff --git a/packages/assets-controller/src/selectors/balance.ts b/packages/assets-controller/src/selectors/balance.ts index b89e61ac810..ed0933c0e07 100644 --- a/packages/assets-controller/src/selectors/balance.ts +++ b/packages/assets-controller/src/selectors/balance.ts @@ -395,13 +395,9 @@ export function getAggregatedBalanceForAccount( internalAccountsOrAccountIds?: InternalAccount[] | AccountId[], accountsById?: AccountsById, ): AggregatedBalanceForAccount { - const { assetsBalance, assetsMetadata, assetPreferences, assetsPrice } = - state; + const { assetsBalance, assetsInfo, assetPreferences, assetsPrice } = state; - const metadata = (assetsMetadata ?? {}) as Record< - Caip19AssetId, - AssetMetadata - >; + const metadata = (assetsInfo ?? {}) as Record; const hasPrices = Boolean(assetsPrice) && diff --git a/packages/assets-controller/src/types.ts b/packages/assets-controller/src/types.ts index 11b3f204c93..5365db54c1c 100644 --- a/packages/assets-controller/src/types.ts +++ b/packages/assets-controller/src/types.ts @@ -351,7 +351,7 @@ export type DataRequest = { */ export type DataResponse = { /** Metadata for assets (shared across accounts) */ - assetsMetadata?: Record; + assetsInfo?: Record; /** Price data for assets (shared across accounts) */ assetsPrice?: Record; /** Balance data per account */ @@ -419,7 +419,7 @@ export type MiddlewareDataSource = { * Internal state structure for AssetsController following normalized design. * * Keys use CAIP identifiers: - * - assetsMetadata keys: CAIP-19 asset IDs (e.g., "eip155:1/erc20:0x...") + * - assetsInfo keys: CAIP-19 asset IDs (e.g., "eip155:1/erc20:0x...") * - assetsBalance outer keys: Account IDs (InternalAccount.id UUIDs) * - assetsBalance inner keys: CAIP-19 asset IDs * - assetsPrice keys: CAIP-19 asset IDs @@ -429,7 +429,7 @@ export type MiddlewareDataSource = { */ export type AssetsControllerStateInternal = { /** Shared metadata for all assets (stored once per asset) */ - assetsMetadata: Record; + assetsInfo: Record; /** Per-account balance data */ assetsBalance: Record>; /** Price data for assets */ diff --git a/packages/assets-controller/tsconfig.build.json b/packages/assets-controller/tsconfig.build.json index 85e7933fb37..3dd2cab2e64 100644 --- a/packages/assets-controller/tsconfig.build.json +++ b/packages/assets-controller/tsconfig.build.json @@ -12,7 +12,9 @@ { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../network-enablement-controller/tsconfig.build.json" }, - { "path": "../permission-controller/tsconfig.build.json" } + { "path": "../permission-controller/tsconfig.build.json" }, + { "path": "../preferences-controller/tsconfig.build.json" }, + { "path": "../assets-controllers/tsconfig.build.json" } ], "include": ["../../types", "./src"], "exclude": ["**/*.test.ts", "**/__fixtures__/"] diff --git a/packages/assets-controller/tsconfig.json b/packages/assets-controller/tsconfig.json index ead4d7d906e..5fa36386931 100644 --- a/packages/assets-controller/tsconfig.json +++ b/packages/assets-controller/tsconfig.json @@ -9,7 +9,9 @@ { "path": "../core-backend" }, { "path": "../keyring-controller" }, { "path": "../messenger" }, - { "path": "../network-enablement-controller" } + { "path": "../network-enablement-controller" }, + { "path": "../preferences-controller" }, + { "path": "../assets-controllers" } ], "include": ["../../types", "./src"] } diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index f026be9e741..3ad40a8f6f5 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,10 +9,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Bump `@metamask/phishing-controller` from `^16.2.0` to `^16.3.0` ([#7979](https://github.com/MetaMask/core/pull/7979)) +- Bump `@metamask/network-enablement-controller` from `^4.1.0` to `^4.1.1` ([#7984](https://github.com/MetaMask/core/pull/7984)) + +## [99.4.0] + +### Added + +- **BREAKING:** `MultichainAssetsControllerMessenger` now requires the `PhishingController:bulkScanTokens` action to be allowed ([#7923](https://github.com/MetaMask/core/pull/7923)) + - Consumers constructing the messenger must include this action in the allowed actions list +- Add Blockaid token security scanning to `MultichainAssetsController` to filter out spam, malicious, and warning tokens during automatic asset detection ([#7923](https://github.com/MetaMask/core/pull/7923)) + - Tokens with `assetNamespace` of "token" (e.g. SPL tokens) are scanned via the `PhishingController:bulkScanTokens` messenger action + - Only tokens with a `Benign` result are kept; native assets (e.g. `slip44`) are not scanned + - The filter fails open: if the scan is unreachable or returns an error, all tokens are kept + - Filtering applies to account-added and asset-list-updated events; `addAssets` (curated list) is not filtered + - Token addresses are batched into groups of 100 to stay within the `bulkScanTokens` per-request limit +- `CodefiTokenPricesServiceV2` now supports fetching prices of ETH on Ink Mainnet (chain `0xdef1`) ([#7688](https://github.com/MetaMask/core/pull/7688)) +- Added Chiliz Chain native token ([#7939](https://github.com/MetaMask/core/pull/7939)) + +### Changed + +- Changed Plasma native token ([#7939](https://github.com/MetaMask/core/pull/7939)) +- `searchTokens` now returns an optional `error` field when requests fail, allowing consumers to detect and handle search failures instead of silently receiving empty results ([#7938](https://github.com/MetaMask/core/pull/7938)) + +## [99.3.2] + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) -- Bump `@metamask/account-tree-controller` from `^4.0.0` to `^4.1.0` ([#7869](https://github.com/MetaMask/core/pull/7869)) -- Bump `@metamask/multichain-account-service` from `^5.1.0` to `^6.0.0` ([#7869](https://github.com/MetaMask/core/pull/7869)) -- Bump `@metamask/transaction-controller` from `^62.15.0` to `^62.16.0` ([#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/account-tree-controller` from `^4.0.0` to `^4.1.1` ([#7869](https://github.com/MetaMask/core/pull/7869)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/multichain-account-service` from `^5.1.0` to `^7.0.0` ([#7869](https://github.com/MetaMask/core/pull/7869)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/transaction-controller` from `^62.15.0` to `^62.17.0` ([#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/phishing-controller` from `^16.1.0` to `^16.2.0` ([#7883](https://github.com/MetaMask/core/pull/7883)) - Optimize Price API performance by deduplicating concurrent API calls ([#7811](https://github.com/MetaMask/core/pull/7811)) - Add in-flight promise caching for `fetchSupportedNetworks()` to prevent duplicate concurrent requests @@ -2665,7 +2693,9 @@ 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@99.3.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@99.4.0...HEAD +[99.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@99.3.2...@metamask/assets-controllers@99.4.0 +[99.3.2]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@99.3.1...@metamask/assets-controllers@99.3.2 [99.3.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@99.3.0...@metamask/assets-controllers@99.3.1 [99.3.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@99.2.0...@metamask/assets-controllers@99.3.0 [99.2.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@99.1.0...@metamask/assets-controllers@99.2.0 diff --git a/packages/assets-controllers/jest.environment.js b/packages/assets-controllers/jest.environment.js index b77d5478109..da37ea53702 100644 --- a/packages/assets-controllers/jest.environment.js +++ b/packages/assets-controllers/jest.environment.js @@ -1,9 +1,9 @@ -const JSDOMEnvironment = require('jest-environment-jsdom'); +const { TestEnvironment } = require('jest-environment-jsdom'); // Custom test environment copied from https://github.com/jsdom/jsdom/issues/2524 // in order to add TextEncoder to jsdom. TextEncoder is expected by jose. -module.exports = class CustomTestEnvironment extends JSDOMEnvironment { +module.exports = class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); if (typeof this.global.TextEncoder === 'undefined') { diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 47fe09c0f73..04237facbdb 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "99.3.1", + "version": "99.4.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "MetaMask", @@ -55,8 +55,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/abi-utils": "^2.0.3", - "@metamask/account-tree-controller": "^4.1.0", - "@metamask/accounts-controller": "^35.0.2", + "@metamask/account-tree-controller": "^4.1.1", + "@metamask/accounts-controller": "^36.0.0", "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/contract-metadata": "^2.4.0", @@ -67,11 +67,11 @@ "@metamask/keyring-controller": "^25.1.0", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", - "@metamask/multichain-account-service": "^6.0.0", + "@metamask/multichain-account-service": "^7.0.0", "@metamask/network-controller": "^29.0.0", - "@metamask/network-enablement-controller": "^4.1.0", + "@metamask/network-enablement-controller": "^4.1.1", "@metamask/permission-controller": "^12.2.0", - "@metamask/phishing-controller": "^16.2.0", + "@metamask/phishing-controller": "^16.3.0", "@metamask/polling-controller": "^16.0.2", "@metamask/preferences-controller": "^22.1.0", "@metamask/profile-sync-controller": "^27.1.0", @@ -80,7 +80,7 @@ "@metamask/snaps-sdk": "^10.3.0", "@metamask/snaps-utils": "^11.7.0", "@metamask/storage-service": "^1.0.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "@types/bn.js": "^5.1.5", "@types/uuid": "^8.3.0", @@ -103,16 +103,15 @@ "@metamask/keyring-snap-client": "^8.2.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.191", "@types/node": "^16.18.54", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "webextension-polyfill": "^0.12.0" diff --git a/packages/assets-controllers/src/AccountTrackerController.test.ts b/packages/assets-controllers/src/AccountTrackerController.test.ts index 8559bfd730b..fb113759664 100644 --- a/packages/assets-controllers/src/AccountTrackerController.test.ts +++ b/packages/assets-controllers/src/AccountTrackerController.test.ts @@ -17,15 +17,13 @@ import { getDefaultPreferencesState } from '@metamask/preferences-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import BN from 'bn.js'; -import { useFakeTimers } from 'sinon'; -import type { SinonFakeTimers } from 'sinon'; import type { AccountTrackerControllerMessenger } from './AccountTrackerController'; import { AccountTrackerController } from './AccountTrackerController'; import { AccountsApiBalanceFetcher } from './multi-chain-accounts-service/api-balance-fetcher'; import { getTokenBalancesForMultipleAddresses } from './multicall'; import { FakeProvider } from '../../../tests/fake-provider'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import { buildCustomNetworkClientConfiguration, @@ -89,10 +87,8 @@ const { safelyExecuteWithTimeout } = jest.requireMock( const mockedSafelyExecuteWithTimeout = safelyExecuteWithTimeout as jest.Mock; describe('AccountTrackerController', () => { - let clock: SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers(); mockedQuery.mockReturnValue(Promise.resolve('0x0')); // Set up default mock for multicall function (without staked balances) @@ -120,7 +116,7 @@ describe('AccountTrackerController', () => { }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); mockedQuery.mockRestore(); mockedGetTokenBalancesForMultipleAddresses.mockClear(); mockedSafelyExecuteWithTimeout.mockRestore(); @@ -188,7 +184,7 @@ describe('AccountTrackerController', () => { transactionMeta, ); - await clock.tickAsync(1); + await jest.advanceTimersByTimeAsync(1); expect( controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, @@ -231,7 +227,7 @@ describe('AccountTrackerController', () => { transactionMeta, ); - await clock.tickAsync(1); + await jest.advanceTimersByTimeAsync(1); // Both from and to addresses should have their balances refreshed expect( @@ -276,7 +272,7 @@ describe('AccountTrackerController', () => { transactionMeta, ); - await clock.tickAsync(1); + await jest.advanceTimersByTimeAsync(1); expect( controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, @@ -319,7 +315,7 @@ describe('AccountTrackerController', () => { transactionMeta, ); - await clock.tickAsync(1); + await jest.advanceTimersByTimeAsync(1); // Both from and to addresses should have their balances refreshed expect( @@ -362,7 +358,7 @@ describe('AccountTrackerController', () => { ); // Refresh only for mainnet - await refresh(clock, ['mainnet']); + await refresh(['mainnet']); // Verify mainnet balance was updated expect( @@ -397,7 +393,7 @@ describe('AccountTrackerController', () => { rpcEndpoints: [{ networkClientId: 'mainnet' }], } as unknown as NetworkConfiguration); - await clock.tickAsync(1); + await jest.advanceTimersByTimeAsync(1); expect( controller.state.accountsByChainId['0x1'][CHECKSUM_ADDRESS_1].balance, @@ -468,7 +464,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual(expectedState); }, @@ -492,7 +488,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Balances should remain at 0x0 because isOnboarded returns false expect(controller.state).toStrictEqual(expectedState); @@ -511,7 +507,7 @@ describe('AccountTrackerController', () => { }, async ({ controller, refresh }) => { // First call: isOnboarded returns false, should skip fetching - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); // Balances should remain at 0x0 expect( @@ -532,7 +528,7 @@ describe('AccountTrackerController', () => { }); // Second call: isOnboarded now returns true, should fetch balances - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); // Balance should now be updated expect( @@ -566,7 +562,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -603,7 +599,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -637,7 +633,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -671,7 +667,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -709,7 +705,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -751,7 +747,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -796,7 +792,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -845,7 +841,7 @@ describe('AccountTrackerController', () => { listAccounts: [ACCOUNT_1, ACCOUNT_2], }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Line 743 should have created an account entry with balance '0x0' for ADDRESS_1 // when applying staked balance without a native balance entry @@ -906,7 +902,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['networkClientId1'], true); + await refresh(['networkClientId1'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { '0x1': { @@ -952,7 +948,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['networkClientId1'], true); + await refresh(['networkClientId1'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -997,7 +993,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['networkClientId1'], false); + await refresh(['networkClientId1'], false); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -1041,7 +1037,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['networkClientId1'], true); + await refresh(['networkClientId1'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -1090,7 +1086,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -1139,7 +1135,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], false); + await refresh(['mainnet'], false); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -1191,7 +1187,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -1242,7 +1238,7 @@ describe('AccountTrackerController', () => { }, }, async ({ controller, refresh }) => { - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); expect(controller.state).toStrictEqual({ accountsByChainId: { @@ -1290,7 +1286,7 @@ describe('AccountTrackerController', () => { }, async ({ controller, refresh }) => { // Should not throw an error, even for unsupported chains - await refresh(clock, ['networkClientId1'], true); + await refresh(['networkClientId1'], true); // State should still be updated with chain entry from syncAccounts expect(controller.state.accountsByChainId).toHaveProperty('0x5'); @@ -1332,7 +1328,7 @@ describe('AccountTrackerController', () => { ); // Start refresh with the mocked timeout behavior - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // With safelyExecuteWithTimeout, timeouts are handled gracefully // The system should continue operating without throwing errors @@ -1374,7 +1370,7 @@ describe('AccountTrackerController', () => { mockedQuery.mockResolvedValue('0x0'); // Refresh balances for mainnet (supported by API) - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Since allowExternalServices defaults to () => true (line 390), and accountsApiChainIds includes '0x1', // the API fetcher should be used, which means fetch should be called @@ -1407,7 +1403,7 @@ describe('AccountTrackerController', () => { mockedQuery.mockResolvedValue('0x0'); // Refresh balances for mainnet (supported by API) - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Since allowExternalServices is true and accountsApiChainIds returns ['0x1'], // the API fetcher should be used, which means fetch should be called @@ -1440,7 +1436,7 @@ describe('AccountTrackerController', () => { mockedQuery.mockResolvedValue('0x0'); // Refresh balances for mainnet - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Since allowExternalServices is false, the API fetcher should NOT be used // Only RPC calls should be made, so fetch should NOT be called @@ -1475,7 +1471,7 @@ describe('AccountTrackerController', () => { mockedQuery.mockResolvedValue('0x123456'); // Refresh balances for mainnet - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Verify that the supports method was called (meaning we reached the continue logic) expect(supportsSpy).toHaveBeenCalledWith('0x1'); @@ -1514,7 +1510,7 @@ describe('AccountTrackerController', () => { mockedQuery.mockResolvedValue('0x123456'); // Refresh balances for mainnet - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // Verify that console.warn was called with the error message expect(consoleWarnSpy).toHaveBeenCalledWith( @@ -1558,7 +1554,7 @@ describe('AccountTrackerController', () => { }); // Refresh balances for mainnet - await refresh(clock, ['mainnet'], true); + await refresh(['mainnet'], true); // The RPC fetcher should have been used as fallback after API returned unprocessedChainIds expect( @@ -1741,15 +1737,15 @@ describe('AccountTrackerController', () => { networkClientIds: ['networkClientId1'], queryAllAccounts: true, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: 50 }); + await jestAdvanceTime({ duration: 50 }); expect(pollSpy).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: 50 }); + await jestAdvanceTime({ duration: 50 }); expect(pollSpy).toHaveBeenCalledTimes(2); }, @@ -1776,12 +1772,12 @@ describe('AccountTrackerController', () => { queryAllAccounts: true, }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(refreshSpy).toHaveBeenNthCalledWith(1, [networkClientId1], true); expect(refreshSpy).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: 50 }); + await jestAdvanceTime({ duration: 50 }); expect(refreshSpy).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: 50 }); + await jestAdvanceTime({ duration: 50 }); expect(refreshSpy).toHaveBeenNthCalledWith(2, [networkClientId1], true); expect(refreshSpy).toHaveBeenCalledTimes(2); @@ -1790,23 +1786,23 @@ describe('AccountTrackerController', () => { queryAllAccounts: true, }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(refreshSpy).toHaveBeenNthCalledWith(3, [networkClientId2], true); expect(refreshSpy).toHaveBeenCalledTimes(3); - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); expect(refreshSpy).toHaveBeenNthCalledWith(4, [networkClientId1], true); expect(refreshSpy).toHaveBeenNthCalledWith(5, [networkClientId2], true); expect(refreshSpy).toHaveBeenCalledTimes(5); controller.stopPollingByPollingToken(pollToken); - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); expect(refreshSpy).toHaveBeenNthCalledWith(6, [networkClientId1], true); expect(refreshSpy).toHaveBeenCalledTimes(6); controller.stopAllPolling(); - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); expect(refreshSpy).toHaveBeenCalledTimes(6); }, @@ -1829,7 +1825,7 @@ describe('AccountTrackerController', () => { queryAllAccounts: true, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(refreshSpy).toHaveBeenCalledTimes(1); }, ); @@ -1844,7 +1840,7 @@ describe('AccountTrackerController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -1856,7 +1852,7 @@ describe('AccountTrackerController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -1869,9 +1865,9 @@ describe('AccountTrackerController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "accountsByChainId": Object { - "0x1": Object {}, + { + "accountsByChainId": { + "0x1": {}, }, } `); @@ -1887,9 +1883,9 @@ describe('AccountTrackerController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "accountsByChainId": Object { - "0x1": Object {}, + { + "accountsByChainId": { + "0x1": {}, }, } `); @@ -1905,7 +1901,6 @@ type WithControllerCallback = ({ messenger: RootMessenger; triggerSelectedAccountChange: (account: InternalAccount) => void; refresh: ( - clock: SinonFakeTimers, networkClientIds: NetworkClientId[], queryAllAccounts?: boolean, ) => Promise; @@ -2099,12 +2094,11 @@ async function withController( }); const refresh = async ( - clock: SinonFakeTimers, networkClientIds: NetworkClientId[], queryAllAccounts?: boolean, ) => { const promise = controller.refresh(networkClientIds, queryAllAccounts); - await clock.tickAsync(1); + await jest.advanceTimersByTimeAsync(1); await promise; }; diff --git a/packages/assets-controllers/src/CurrencyRateController.test.ts b/packages/assets-controllers/src/CurrencyRateController.test.ts index 7a05b7601e3..c5cae3b9bfb 100644 --- a/packages/assets-controllers/src/CurrencyRateController.test.ts +++ b/packages/assets-controllers/src/CurrencyRateController.test.ts @@ -12,12 +12,11 @@ import type { } from '@metamask/messenger'; import type { NetworkConfiguration } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; -import { useFakeTimers } from 'sinon'; import type { CurrencyRateMessenger } from './CurrencyRateController'; import { CurrencyRateController } from './CurrencyRateController'; import type { AbstractTokenPricesService } from './token-prices-service'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; const namespace = 'CurrencyRateController'; @@ -174,13 +173,12 @@ const getStubbedDate = () => { }; describe('CurrencyRateController', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('should set default state', () => { @@ -242,7 +240,7 @@ describe('CurrencyRateController', () => { tokenPricesService, }); - await advanceTime({ clock, duration: 200 }); + await jestAdvanceTime({ duration: 200 }); expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); @@ -277,7 +275,7 @@ describe('CurrencyRateController', () => { }); controller.startPolling({ nativeCurrencies: ['ETH'] }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); expect(controller.state.currencyRates).toStrictEqual({ @@ -287,11 +285,11 @@ describe('CurrencyRateController', () => { usdConversionRate: null, }, }); - await advanceTime({ clock, duration: 99 }); + await jestAdvanceTime({ duration: 99 }); expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(2); expect(controller.state.currencyRates).toStrictEqual({ @@ -327,14 +325,14 @@ describe('CurrencyRateController', () => { controller.startPolling({ nativeCurrencies: ['ETH'] }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.stopAllPolling(); // called once upon initial start expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: 150, stepSize: 50 }); + await jestAdvanceTime({ duration: 150, stepSize: 50 }); expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); @@ -361,7 +359,7 @@ describe('CurrencyRateController', () => { tokenPricesService, }); controller.startPolling({ nativeCurrencies: ['ETH'] }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.stopAllPolling(); @@ -369,11 +367,11 @@ describe('CurrencyRateController', () => { expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(1); controller.startPolling({ nativeCurrencies: ['ETH'] }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(2); - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); expect(fetchExchangeRatesSpy).toHaveBeenCalledTimes(3); }); @@ -530,7 +528,7 @@ describe('CurrencyRateController', () => { }, }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller.state).toStrictEqual({ currentCurrency, @@ -923,11 +921,11 @@ describe('CurrencyRateController', () => { }); controller.startPolling({ nativeCurrencies: ['ETH'] }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); expect(fetchExchangeRatesSpy).not.toHaveBeenCalled(); @@ -2111,9 +2109,9 @@ describe('CurrencyRateController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "currencyRates": Object { - "ETH": Object { + { + "currencyRates": { + "ETH": { "conversionDate": 0, "conversionRate": 0, "usdConversionRate": null, @@ -2138,9 +2136,9 @@ describe('CurrencyRateController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "currencyRates": Object { - "ETH": Object { + { + "currencyRates": { + "ETH": { "conversionDate": 0, "conversionRate": 0, "usdConversionRate": null, @@ -2165,9 +2163,9 @@ describe('CurrencyRateController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "currencyRates": Object { - "ETH": Object { + { + "currencyRates": { + "ETH": { "conversionDate": 0, "conversionRate": 0, "usdConversionRate": null, @@ -2191,9 +2189,9 @@ describe('CurrencyRateController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "currencyRates": Object { - "ETH": Object { + { + "currencyRates": { + "ETH": { "conversionDate": 0, "conversionRate": 0, "usdConversionRate": null, diff --git a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts index d3df3f8925f..ee9615fa00a 100644 --- a/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts +++ b/packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts @@ -505,7 +505,7 @@ describe('DeFiPositionsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -517,7 +517,7 @@ describe('DeFiPositionsController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -529,7 +529,7 @@ describe('DeFiPositionsController', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -542,8 +542,8 @@ describe('DeFiPositionsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "allDeFiPositions": Object {}, + { + "allDeFiPositions": {}, } `); }); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts index 7ead75e7e8d..adab7ffeee6 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.test.ts @@ -20,8 +20,9 @@ import type { } from '@metamask/messenger'; import type { PermissionConstraint } from '@metamask/permission-controller'; import type { SubjectPermissions } from '@metamask/permission-controller'; +import type { BulkTokenScanResponse } from '@metamask/phishing-controller'; +import { TokenScanResultType } from '@metamask/phishing-controller'; import type { Snap } from '@metamask/snaps-utils'; -import { useFakeTimers } from 'sinon'; import { v4 as uuidv4 } from 'uuid'; import { @@ -33,7 +34,7 @@ import type { MultichainAssetsControllerMessenger, MultichainAssetsControllerState, } from './MultichainAssetsController'; -import { advanceTime } from '../../../../tests/helpers'; +import { jestAdvanceTime } from '../../../../tests/helpers'; const mockSolanaAccount: InternalAccount = { type: 'solana:data-account', @@ -267,6 +268,7 @@ const setupController = ({ 'SnapController:handleRequest', 'SnapController:getAll', 'PermissionController:getPermissions', + 'PhishingController:bulkScanTokens', ], events: [ 'AccountsController:accountAdded', @@ -308,6 +310,12 @@ const setupController = ({ ), ); + const mockBulkScanTokens = jest.fn(); + messenger.registerActionHandler( + 'PhishingController:bulkScanTokens', + mockBulkScanTokens.mockResolvedValue({}), + ); + const controller = new MultichainAssetsController({ messenger: multichainAssetsControllerMessenger, state, @@ -320,18 +328,17 @@ const setupController = ({ mockListMultichainAccounts, mockGetAllSnaps, mockGetPermissions, + mockBulkScanTokens, }; }; describe('MultichainAssetsController', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('initialize with default state', () => { const { controller } = setupController({}); @@ -350,7 +357,7 @@ describe('MultichainAssetsController', () => { mockEthAccount as unknown as InternalAccount, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ accountsAssets: {}, @@ -381,7 +388,7 @@ describe('MultichainAssetsController', () => { mockSolanaAccount as unknown as InternalAccount, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ accountsAssets: { @@ -445,7 +452,7 @@ describe('MultichainAssetsController', () => { mockSolanaAccount as unknown as InternalAccount, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(mockSnapHandleRequest).toHaveBeenCalledTimes(3); @@ -503,7 +510,7 @@ describe('MultichainAssetsController', () => { mockSolanaAccount as unknown as InternalAccount, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(mockSnapHandleRequest).toHaveBeenCalledTimes(3); @@ -541,7 +548,7 @@ describe('MultichainAssetsController', () => { mockSolanaAccount as unknown as InternalAccount, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ accountsAssets: { @@ -554,7 +561,7 @@ describe('MultichainAssetsController', () => { // Remove an EVM account messenger.publish('AccountsController:accountRemoved', mockEthAccount.id); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ accountsAssets: { @@ -589,7 +596,7 @@ describe('MultichainAssetsController', () => { mockSolanaAccount as unknown as InternalAccount, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ accountsAssets: { @@ -605,7 +612,7 @@ describe('MultichainAssetsController', () => { mockSolanaAccount.id, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ accountsAssets: {}, @@ -681,7 +688,7 @@ describe('MultichainAssetsController', () => { updatedAssetsList, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state.accountsAssets).toStrictEqual({ [mockSolanaAccountId1]: [ @@ -742,7 +749,7 @@ describe('MultichainAssetsController', () => { 'AccountsController:accountAssetListUpdated', updatedAssetsList, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state.accountsAssets).toStrictEqual({ [mockSolanaAccountId1]: [ @@ -786,7 +793,7 @@ describe('MultichainAssetsController', () => { 'AccountsController:accountAssetListUpdated', updatedAssetsList, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state.accountsAssets).toStrictEqual({ [mockSolanaAccountId1]: [ @@ -1259,7 +1266,7 @@ describe('MultichainAssetsController', () => { }); // Wait for async processing - await advanceTime({ clock: useFakeTimers(), duration: 0 }); + await jestAdvanceTime({ duration: 0 }); // Only the non-ignored asset should be added expect( @@ -1296,7 +1303,7 @@ describe('MultichainAssetsController', () => { }); // Wait for async processing - await advanceTime({ clock: useFakeTimers(), duration: 0 }); + await jestAdvanceTime({ duration: 0 }); // Ignored asset should remain filtered out and stay in ignored list expect( @@ -1327,7 +1334,7 @@ describe('MultichainAssetsController', () => { messenger.publish('AccountsController:accountAdded', mockSolanaAccount); // Wait for async processing - await advanceTime({ clock: useFakeTimers(), duration: 0 }); + await jestAdvanceTime({ duration: 0 }); // All assets should be added to active list (no ignored assets for new account) expect( @@ -1366,7 +1373,7 @@ describe('MultichainAssetsController', () => { ); // Wait for async processing - await advanceTime({ clock: useFakeTimers(), duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect( controller.state.accountsAssets[mockSolanaAccount.id], @@ -1377,6 +1384,419 @@ describe('MultichainAssetsController', () => { }); }); + describe('Blockaid token filtering', () => { + it('filters out spam tokens when account is added', async () => { + const benignToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + const spamToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SpamTokenAddress'; + const nativeToken = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + mocks: { + handleRequestReturnValue: [nativeToken, benignToken, spamToken], + }, + }); + + mockBulkScanTokens.mockResolvedValue({ + Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + }, + SpamTokenAddress: { + result_type: TokenScanResultType.Spam, + chain: 'solana', + address: 'SpamTokenAddress', + }, + }); + + messenger.publish( + 'AccountsController:accountAdded', + mockSolanaAccount as unknown as InternalAccount, + ); + + await jestAdvanceTime({ duration: 1 }); + + // Native token (slip44) should pass through unfiltered + // Benign token should be kept + // Spam token should be filtered out + expect( + controller.state.accountsAssets[mockSolanaAccount.id], + ).toStrictEqual([nativeToken, benignToken]); + + // Verify bulkScanTokens was called with correct parameters + expect(mockBulkScanTokens).toHaveBeenCalledWith({ + chainId: 'solana', + tokens: [ + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'SpamTokenAddress', + ], + }); + }); + + it('filters out malicious tokens in accountAssetListUpdated', async () => { + const mockAccountId = 'account1'; + const maliciousToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:MaliciousAddr'; + const benignToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:BenignAddr'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { + [mockAccountId]: [], + }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + MaliciousAddr: { + result_type: TokenScanResultType.Malicious, + chain: 'solana', + address: 'MaliciousAddr', + }, + BenignAddr: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'BenignAddr', + }, + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { + added: [maliciousToken, benignToken], + removed: [], + }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Malicious token should be filtered out + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + benignToken, + ]); + }); + + it('keeps all tokens when bulkScanTokens throws (fail open)', async () => { + const mockAccountId = 'account1'; + const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SomeAddr'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockRejectedValue(new Error('Scanning failed')); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: [token], removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Token should be kept when scan throws + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + token, + ]); + }); + + it('keeps all tokens when bulkScanTokens returns empty (API error handled internally)', async () => { + const mockAccountId = 'account1'; + const token = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SomeAddr'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + // PhishingController returns {} when the API fails or times out + mockBulkScanTokens.mockResolvedValue({}); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: [token], removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Token should be kept when scan returns empty (no result = fail open) + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + token, + ]); + }); + + it('does not scan native (slip44) assets', async () => { + const mockAccountId = 'account1'; + const nativeToken = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: [nativeToken], removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Native token should pass through without scan call + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + nativeToken, + ]); + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + }); + + it('keeps tokens with no result in the scan response (fail open)', async () => { + const mockAccountId = 'account1'; + const knownToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:KnownAddr'; + const unknownToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:UnknownAddr'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + // Only return result for knownToken, not unknownToken + mockBulkScanTokens.mockResolvedValue({ + KnownAddr: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'KnownAddr', + }, + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: [knownToken, unknownToken], removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Both tokens should be kept (unknown token has no result, fail open) + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([ + knownToken, + unknownToken, + ]); + }); + + it('filters out Warning tokens via accountAssetListUpdated', async () => { + const mockAccountId = 'account1'; + const warningToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:WarningAddr'; + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + mockBulkScanTokens.mockResolvedValue({ + WarningAddr: { + result_type: TokenScanResultType.Warning, + chain: 'solana', + address: 'WarningAddr', + }, + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: [warningToken], removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Warning token should be filtered out; account has no assets added + expect(controller.state.accountsAssets[mockAccountId]).toStrictEqual([]); + }); + + it('does not filter tokens in addAssets (curated list)', async () => { + const spamToken = + 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:SpamAddr'; + + const { controller, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockSolanaAccount.id]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + const result = await controller.addAssets( + [spamToken], + mockSolanaAccount.id, + ); + + // addAssets comes from extension curated list — no Blockaid filtering + expect(result).toStrictEqual([spamToken]); + expect(mockBulkScanTokens).not.toHaveBeenCalled(); + }); + + it('batches token scan calls when there are more than 100 tokens', async () => { + const mockAccountId = 'account1'; + // Generate 150 tokens so we exceed the 100-per-request limit + const tokens = Array.from( + { length: 150 }, + (_, i) => + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`, + ); + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + // Mark the last token in each batch as spam to verify both batches are processed + mockBulkScanTokens.mockImplementation((request: { tokens: string[] }) => { + const results: BulkTokenScanResponse = {}; + for (const addr of request.tokens) { + // Token099 (last in batch 1) and Token149 (last in batch 2) are spam + if (addr === 'Token099' || addr === 'Token149') { + results[addr] = { + result_type: TokenScanResultType.Spam, + chain: 'solana', + address: addr, + }; + } else { + results[addr] = { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: addr, + }; + } + } + return Promise.resolve(results); + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: tokens, removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + // Should have been called twice: once with 100 tokens, once with 50 + expect(mockBulkScanTokens).toHaveBeenCalledTimes(2); + expect(mockBulkScanTokens.mock.calls[0][0].tokens).toHaveLength(100); + expect(mockBulkScanTokens.mock.calls[1][0].tokens).toHaveLength(50); + + // Both spam tokens should be filtered out + const storedAssets = controller.state.accountsAssets[mockAccountId]; + expect(storedAssets).toHaveLength(148); + expect( + storedAssets.find( + (a: string) => + a === 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token099', + ), + ).toBeUndefined(); + expect( + storedAssets.find( + (a: string) => + a === 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token149', + ), + ).toBeUndefined(); + }); + + it('keeps results from successful batches when one batch fails (partial fail open)', async () => { + const mockAccountId = 'account1'; + // 120 tokens = batch 1 (100) + batch 2 (20) + const tokens = Array.from( + { length: 120 }, + (_, i) => + `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`, + ); + + const { controller, messenger, mockBulkScanTokens } = setupController({ + state: { + accountsAssets: { [mockAccountId]: [] }, + assetsMetadata: {}, + allIgnoredAssets: {}, + } as MultichainAssetsControllerState, + }); + + let callCount = 0; + mockBulkScanTokens.mockImplementation((request: { tokens: string[] }) => { + callCount += 1; + // First batch succeeds — marks Token099 as spam + if (callCount === 1) { + const results: BulkTokenScanResponse = {}; + for (const addr of request.tokens) { + results[addr] = { + result_type: + addr === 'Token099' + ? TokenScanResultType.Spam + : TokenScanResultType.Benign, + chain: 'solana', + address: addr, + }; + } + return Promise.resolve(results); + } + // Second batch fails + return Promise.reject(new Error('API timeout')); + }); + + messenger.publish('AccountsController:accountAssetListUpdated', { + assets: { + [mockAccountId]: { added: tokens, removed: [] }, + }, + }); + + await jestAdvanceTime({ duration: 1 }); + + const storedAssets = controller.state.accountsAssets[mockAccountId]; + + // Token099 from the successful first batch should still be filtered + expect( + storedAssets.find( + (a: string) => + a === 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token099', + ), + ).toBeUndefined(); + + // Tokens from the failed second batch (100–119) should all be kept (fail open) + for (let i = 100; i < 120; i++) { + const tokenCaip = `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/token:Token${String(i).padStart(3, '0')}`; + expect(storedAssets).toContain(tokenCaip); + } + + // Total: 99 benign from batch 1 + 20 kept from failed batch 2 = 119 + expect(storedAssets).toHaveLength(119); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', () => { const { controller } = setupController(); @@ -1387,7 +1807,7 @@ describe('MultichainAssetsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -1399,7 +1819,7 @@ describe('MultichainAssetsController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -1412,10 +1832,10 @@ describe('MultichainAssetsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "accountsAssets": Object {}, - "allIgnoredAssets": Object {}, - "assetsMetadata": Object {}, + { + "accountsAssets": {}, + "allIgnoredAssets": {}, + "assetsMetadata": {}, } `); }); @@ -1430,10 +1850,10 @@ describe('MultichainAssetsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "accountsAssets": Object {}, - "allIgnoredAssets": Object {}, - "assetsMetadata": Object {}, + { + "accountsAssets": {}, + "allIgnoredAssets": {}, + "assetsMetadata": {}, } `); }); diff --git a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts index e7e2e30c142..e2c584dfe00 100644 --- a/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts +++ b/packages/assets-controllers/src/MultichainAssetsController/MultichainAssetsController.ts @@ -24,6 +24,11 @@ import type { PermissionConstraint, SubjectPermissions, } from '@metamask/permission-controller'; +import type { + BulkTokenScanResponse, + PhishingControllerBulkScanTokensAction, +} from '@metamask/phishing-controller'; +import { TokenScanResultType } from '@metamask/phishing-controller'; import type { GetAllSnaps, HandleSnapRequest, @@ -139,7 +144,8 @@ type AllowedActions = | HandleSnapRequest | GetAllSnaps | GetPermissions - | AccountsControllerListMultichainAccountsAction; + | AccountsControllerListMultichainAccountsAction + | PhishingControllerBulkScanTokensAction; /** * Events that this controller is allowed to subscribe. @@ -412,13 +418,18 @@ export class MultichainAssetsController extends BaseController< // In case accountsAndAssetsToUpdate event is fired with "added" assets that already exist, we don't want to add them again // Also filter out ignored assets - const filteredToBeAddedAssets = added.filter( + const preFilteredToBeAddedAssets = added.filter( (asset) => !existing.includes(asset) && isCaipAssetType(asset) && !this.#isAssetIgnored(asset, accountId), ); + // Filter out tokens flagged by Blockaid as non-benign + const filteredToBeAddedAssets = await this.#filterBlockaidSpamTokens( + preFilteredToBeAddedAssets, + ); + // In case accountsAndAssetsToUpdate event is fired with "removed" assets that don't exist, we don't want to remove them const filteredToBeRemovedAssets = removed.filter( (asset) => existing.includes(asset) && isCaipAssetType(asset), @@ -498,10 +509,11 @@ export class MultichainAssetsController extends BaseController< // Get assets list if (account.metadata.snap) { - const assets = await this.#getAssetsList( + const allAssets = await this.#getAssetsList( account.id, account.metadata.snap.id, ); + const assets = await this.#filterBlockaidSpamTokens(allAssets); await this.#refreshAssetsMetadata(assets); this.update((state) => { state.accountsAssets[account.id] = assets; @@ -702,6 +714,97 @@ export class MultichainAssetsController extends BaseController< } } + /** + * Filters out tokens flagged as non-benign by Blockaid via the + * `PhishingController:bulkScanTokens` messenger action. Only tokens with + * an `assetNamespace` of "token" are scanned (native assets like slip44 are + * passed through unfiltered). If the scan fails, all tokens are kept + * (fail open). + * + * @param assets - The CAIP asset type list to filter. + * @returns The filtered list with malicious/spam/warning tokens removed. + */ + async #filterBlockaidSpamTokens( + assets: CaipAssetType[], + ): Promise { + // Group scannable token assets by chain namespace + const tokensByChain: Record< + string, + { asset: CaipAssetType; address: string }[] + > = {}; + + for (const asset of assets) { + const { assetNamespace, assetReference, chain } = + parseCaipAssetType(asset); + + // Only scan fungible token assets (e.g. SPL tokens), skip native (slip44) + if (assetNamespace === 'token') { + const chainName = chain.namespace; + if (!tokensByChain[chainName]) { + tokensByChain[chainName] = []; + } + tokensByChain[chainName].push({ asset, address: assetReference }); + } + } + + // If there are no token assets to scan, return as-is + if (Object.keys(tokensByChain).length === 0) { + return assets; + } + + // Build a set of assets to reject (non-benign tokens) + const rejectedAssets = new Set(); + + // PhishingController:bulkScanTokens rejects requests with more than + // 100 tokens (returning {}). Batch addresses into chunks to stay within + // the limit. + const BATCH_SIZE = 100; + + for (const [chainName, tokenEntries] of Object.entries(tokensByChain)) { + const addresses = tokenEntries.map((entry) => entry.address); + + // Create batches of BATCH_SIZE + const batches: string[][] = []; + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + batches.push(addresses.slice(i, i + BATCH_SIZE)); + } + + // Scan all batches in parallel. Using Promise.allSettled so that a + // single batch failure doesn't discard results from successful batches + // (fail open at the batch level, not the chain level). + const batchResults = await Promise.allSettled( + batches.map((batch) => + this.messenger.call('PhishingController:bulkScanTokens', { + chainId: chainName, + tokens: batch, + }), + ), + ); + + // Merge results from fulfilled batches (rejected batches fail open) + const scanResponse: BulkTokenScanResponse = {}; + for (const result of batchResults) { + if (result.status === 'fulfilled') { + Object.assign(scanResponse, result.value); + } + } + + for (const entry of tokenEntries) { + const result = scanResponse[entry.address]; + // Reject the token only if we have a definitive non-benign result + if ( + result?.result_type && + result.result_type !== TokenScanResultType.Benign + ) { + rejectedAssets.add(entry.asset); + } + } + } + + // Filter while preserving original order + return assets.filter((asset) => !rejectedAssets.has(asset)); + } + /** * Get assets list for an account * diff --git a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts index 8d643f7325e..fb79960ae46 100644 --- a/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts +++ b/packages/assets-controllers/src/MultichainAssetsRatesController/MultichainAssetsRatesController.test.ts @@ -13,12 +13,11 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; import type { OnAssetHistoricalPriceResponse } from '@metamask/snaps-sdk'; -import { useFakeTimers } from 'sinon'; import { v4 as uuidv4 } from 'uuid'; import { MultichainAssetsRatesController } from '.'; import type { MultichainAssetsRatesControllerMessenger } from './MultichainAssetsRatesController'; -import { advanceTime } from '../../../../tests/helpers'; +import { jestAdvanceTime } from '../../../../tests/helpers'; type AllMultichainAssetsRateControllerActions = MessengerActions; @@ -245,17 +244,15 @@ const setupController = ({ }; describe('MultichainAssetsRatesController', () => { - let clock: sinon.SinonFakeTimers; - const mockedDate = 1705760550000; beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers(); jest.spyOn(Date, 'now').mockReturnValue(mockedDate); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -510,7 +507,7 @@ describe('MultichainAssetsRatesController', () => { // Wait for the asynchronous subscriber to run. await Promise.resolve(); - await advanceTime({ clock, duration: 10 }); + await jestAdvanceTime({ duration: 10 }); expect(updateSpy).toHaveBeenCalledTimes(1); expect(controller.state.conversionRates).toMatchObject({ @@ -1288,9 +1285,9 @@ describe('MultichainAssetsRatesController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "conversionRates": Object {}, - "historicalPrices": Object {}, + { + "conversionRates": {}, + "historicalPrices": {}, } `); }); @@ -1304,7 +1301,7 @@ describe('MultichainAssetsRatesController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -1317,8 +1314,8 @@ describe('MultichainAssetsRatesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "conversionRates": Object {}, + { + "conversionRates": {}, } `); }); @@ -1333,9 +1330,9 @@ describe('MultichainAssetsRatesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "conversionRates": Object {}, - "historicalPrices": Object {}, + { + "conversionRates": {}, + "historicalPrices": {}, } `); }); diff --git a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts index 6fa56975632..1dfd40564c9 100644 --- a/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts +++ b/packages/assets-controllers/src/MultichainBalancesController/MultichainBalancesController.test.ts @@ -760,7 +760,7 @@ describe('MultichainBalancesController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -772,7 +772,7 @@ describe('MultichainBalancesController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -785,8 +785,8 @@ describe('MultichainBalancesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "balances": Object {}, + { + "balances": {}, } `); }); @@ -801,8 +801,8 @@ describe('MultichainBalancesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "balances": Object {}, + { + "balances": {}, } `); }); diff --git a/packages/assets-controllers/src/NftController.test.ts b/packages/assets-controllers/src/NftController.test.ts index 43d60026d07..681fb35ed0f 100644 --- a/packages/assets-controllers/src/NftController.test.ts +++ b/packages/assets-controllers/src/NftController.test.ts @@ -39,7 +39,6 @@ import type { PreferencesState } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; -import * as sinon from 'sinon'; import { v4 } from 'uuid'; import type { @@ -468,10 +467,6 @@ describe('NftController', () => { }); }); - afterEach(() => { - sinon.restore(); - }); - it('should set default state', () => { const { nftController } = setupController(); @@ -748,7 +743,7 @@ describe('NftController', () => { const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -837,7 +832,7 @@ describe('NftController', () => { true, ); - clock.restore(); + jest.restoreAllMocks(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API enabled and IPFS gateway enabled', async function () { @@ -874,7 +869,7 @@ describe('NftController', () => { const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -963,7 +958,7 @@ describe('NftController', () => { true, ); - clock.restore(); + jest.restoreAllMocks(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API disabled and IPFS gateway disabled', async function () { @@ -1000,7 +995,7 @@ describe('NftController', () => { const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -1089,7 +1084,7 @@ describe('NftController', () => { true, ); - clock.restore(); + jest.restoreAllMocks(); }); it('should handle ERC721 type and add pending request to ApprovalController with the OpenSea API enabled and IPFS gateway disabled', async function () { @@ -1127,7 +1122,7 @@ describe('NftController', () => { const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -1216,7 +1211,7 @@ describe('NftController', () => { true, ); - clock.restore(); + jest.restoreAllMocks(); }); it('should handle ERC1155 type and add to suggestedNfts with the OpenSea API disabled', async function () { @@ -1258,7 +1253,7 @@ describe('NftController', () => { }); const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -1351,7 +1346,7 @@ describe('NftController', () => { true, ); - clock.restore(); + jest.restoreAllMocks(); }); it('should handle ERC1155 type and add to suggestedNfts with the OpenSea API enabled', async function () { @@ -1390,7 +1385,7 @@ describe('NftController', () => { }); const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -1484,7 +1479,7 @@ describe('NftController', () => { true, ); - clock.restore(); + jest.restoreAllMocks(); }); it('should add the NFT to the correct chainId/selectedAddress in state when passed a userAddress in the options argument', async function () { @@ -1516,7 +1511,7 @@ describe('NftController', () => { const requestId = 'approval-request-id-1'; - sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -1619,7 +1614,7 @@ describe('NftController', () => { const requestId = 'approval-request-id-1'; - const clock = sinon.useFakeTimers(1); + jest.spyOn(Date, 'now').mockReturnValue(1); (v4 as jest.Mock).mockImplementationOnce(() => requestId); @@ -1700,7 +1695,7 @@ describe('NftController', () => { }, }); - clock.restore(); + jest.restoreAllMocks(); }); it('should throw an error when calls to `ownerOf` and `balanceOf` revert', async function () { @@ -5985,7 +5980,7 @@ describe('NftController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -5997,7 +5992,7 @@ describe('NftController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -6010,10 +6005,10 @@ describe('NftController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "allNftContracts": Object {}, - "allNfts": Object {}, - "ignoredNfts": Array [], + { + "allNftContracts": {}, + "allNfts": {}, + "ignoredNfts": [], } `); }); @@ -6028,9 +6023,9 @@ describe('NftController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "allNftContracts": Object {}, - "allNfts": Object {}, + { + "allNftContracts": {}, + "allNfts": {}, } `); }); diff --git a/packages/assets-controllers/src/NftDetectionController.test.ts b/packages/assets-controllers/src/NftDetectionController.test.ts index 6b4d6b0a749..545a3ea8610 100644 --- a/packages/assets-controllers/src/NftDetectionController.test.ts +++ b/packages/assets-controllers/src/NftDetectionController.test.ts @@ -24,7 +24,6 @@ import type { import { getDefaultPreferencesState } from '@metamask/preferences-controller'; import type { PreferencesState } from '@metamask/preferences-controller'; import nock from 'nock'; -import sinon from 'sinon'; import { Source } from './constants'; import { getDefaultNftControllerState } from './NftController'; @@ -35,7 +34,7 @@ import { import type { NftDetectionControllerMessenger } from './NftDetectionController'; import { FakeBlockTracker } from '../../../tests/fake-block-tracker'; import { FakeProvider } from '../../../tests/fake-provider'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import { buildMockFindNetworkClientIdByChainId, @@ -53,10 +52,8 @@ const controllerName = 'NftDetectionController' as const; const defaultSelectedAccount = createMockInternalAccount(); describe('NftDetectionController', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(async () => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); nock(NFT_API_BASE_URL) .persist() @@ -360,8 +357,7 @@ describe('NftDetectionController', () => { }); afterEach(() => { - clock.restore(); - sinon.restore(); + jest.useRealTimers(); }); it('should call detect NFTs on mainnet', async () => { @@ -374,9 +370,9 @@ describe('NftDetectionController', () => { mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { - const mockNfts = sinon - .stub(controller, 'detectNfts') - .returns(Promise.resolve()); + const mockNfts = jest + .spyOn(controller, 'detectNfts') + .mockResolvedValue(); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, @@ -384,14 +380,13 @@ describe('NftDetectionController', () => { // call detectNfts await controller.detectNfts(['0x1']); - expect(mockNfts.calledOnce).toBe(true); + expect(mockNfts).toHaveBeenCalledTimes(1); - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 10, }); - expect(mockNfts.calledTwice).toBe(false); + expect(mockNfts).toHaveBeenCalledTimes(1); }, ); }); @@ -484,7 +479,9 @@ describe('NftDetectionController', () => { mockGetSelectedAccount, }, async ({ controller }) => { - const mockNfts = sinon.stub(controller, 'detectNfts'); + const mockNfts = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(); // nock const mockApiCall = nock(NFT_API_BASE_URL) @@ -504,7 +501,7 @@ describe('NftDetectionController', () => { userAddress: selectedAddress, }); - expect(mockNfts.called).toBe(true); + expect(mockNfts).toHaveBeenCalled(); expect(mockApiCall.isDone()).toBe(false); }, ); @@ -530,8 +527,7 @@ describe('NftDetectionController', () => { }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -611,8 +607,7 @@ describe('NftDetectionController', () => { }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -673,8 +668,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -739,8 +733,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -806,18 +799,19 @@ describe('NftDetectionController', () => { await withController( { options: { disabled: false } }, async ({ controller, controllerEvents }) => { - const mockNfts = sinon.stub(controller, 'detectNfts'); + const mockNfts = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(); controllerEvents.triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); - expect(mockNfts.calledOnce).toBe(false); + expect(mockNfts).not.toHaveBeenCalled(); }, ); }); @@ -842,8 +836,7 @@ describe('NftDetectionController', () => { useNftDetection: false, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -882,8 +875,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -910,8 +902,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); // This mock is for the call under test @@ -952,8 +943,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); // Wait for detect call triggered by preferences state change to settle - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -976,7 +966,9 @@ describe('NftDetectionController', () => { mockGetSelectedAccount, }, async ({ controller, controllerEvents }) => { - const detectNfts = sinon.stub(controller, 'detectNfts'); + const detectNfts = jest + .spyOn(controller, 'detectNfts') + .mockImplementation(); // Repeated preference changes should only trigger 1 detection for (let i = 0; i < 5; i++) { @@ -986,8 +978,8 @@ describe('NftDetectionController', () => { securityAlertsEnabled: true, }); } - await advanceTime({ clock, duration: 1 }); - expect(detectNfts.callCount).toBe(0); + await jestAdvanceTime({ duration: 1 }); + expect(detectNfts).not.toHaveBeenCalled(); // Irrelevant preference changes shouldn't trigger a detection controllerEvents.triggerPreferencesStateChange({ @@ -995,8 +987,8 @@ describe('NftDetectionController', () => { useNftDetection: true, securityAlertsEnabled: true, }); - await advanceTime({ clock, duration: 1 }); - expect(detectNfts.callCount).toBe(0); + await jestAdvanceTime({ duration: 1 }); + expect(detectNfts).not.toHaveBeenCalled(); }, ); }); @@ -1109,8 +1101,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); @@ -1222,8 +1213,7 @@ describe('NftDetectionController', () => { useNftDetection: true, }); - await advanceTime({ - clock, + await jestAdvanceTime({ duration: 1, }); mockAddNfts.mockReset(); diff --git a/packages/assets-controllers/src/RatesController/RatesController.test.ts b/packages/assets-controllers/src/RatesController/RatesController.test.ts index 4bd60c28af5..e5db90adf5c 100644 --- a/packages/assets-controllers/src/RatesController/RatesController.test.ts +++ b/packages/assets-controllers/src/RatesController/RatesController.test.ts @@ -5,7 +5,6 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; -import { useFakeTimers } from 'sinon'; import { Cryptocurrency, @@ -13,7 +12,7 @@ import { name as ratesControllerName, } from './RatesController'; import type { RatesControllerMessenger, RatesControllerState } from './types'; -import { advanceTime } from '../../../../tests/helpers'; +import { jestAdvanceTime } from '../../../../tests/helpers'; import type { fetchMultiExchangeRate as defaultFetchExchangeRate } from '../crypto-compare-service'; type AllActions = MessengerActions; @@ -93,8 +92,6 @@ function setupRatesController({ } describe('RatesController', () => { - let clock: sinon.SinonFakeTimers; - describe('construct', () => { it('constructs the RatesController with default values', () => { const { ratesController } = setupRatesController({ @@ -118,11 +115,11 @@ describe('RatesController', () => { describe('start', () => { beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('starts the polling process with default values', async () => { @@ -174,7 +171,7 @@ describe('RatesController', () => { `${ratesControllerName}:pollingStarted`, ); - await advanceTime({ clock, duration: 200 }); + await jestAdvanceTime({ duration: 200 }); const ratesPosUpdate = ratesController.state.rates; @@ -238,7 +235,7 @@ describe('RatesController', () => { await ratesController.start(); - await advanceTime({ clock, duration: 200 }); + await jestAdvanceTime({ duration: 200 }); const { rates } = ratesController.state; expect(fetchExchangeRateStub).toHaveBeenCalled(); @@ -264,11 +261,11 @@ describe('RatesController', () => { describe('stop', () => { beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('stops the polling process', async () => { @@ -291,7 +288,7 @@ describe('RatesController', () => { `${ratesControllerName}:pollingStarted`, ); - await advanceTime({ clock, duration: 200 }); + await jestAdvanceTime({ duration: 200 }); expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); @@ -304,7 +301,7 @@ describe('RatesController', () => { `${ratesControllerName}:pollingStopped`, ); - await advanceTime({ clock, duration: 200 }); + await jestAdvanceTime({ duration: 200 }); expect(fetchExchangeRateStub).toHaveBeenCalledTimes(2); @@ -422,18 +419,18 @@ describe('RatesController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "cryptocurrencies": Array [ + { + "cryptocurrencies": [ "btc", "sol", ], "fiatCurrency": "usd", - "rates": Object { - "btc": Object { + "rates": { + "btc": { "conversionDate": 0, "conversionRate": 0, }, - "sol": Object { + "sol": { "conversionDate": 0, "conversionRate": 0, }, @@ -457,8 +454,8 @@ describe('RatesController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "cryptocurrencies": Array [ + { + "cryptocurrencies": [ "btc", "sol", ], @@ -482,18 +479,18 @@ describe('RatesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "cryptocurrencies": Array [ + { + "cryptocurrencies": [ "btc", "sol", ], "fiatCurrency": "usd", - "rates": Object { - "btc": Object { + "rates": { + "btc": { "conversionDate": 0, "conversionRate": 0, }, - "sol": Object { + "sol": { "conversionDate": 0, "conversionRate": 0, }, @@ -517,14 +514,14 @@ describe('RatesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "fiatCurrency": "usd", - "rates": Object { - "btc": Object { + "rates": { + "btc": { "conversionDate": 0, "conversionRate": 0, }, - "sol": Object { + "sol": { "conversionDate": 0, "conversionRate": 0, }, diff --git a/packages/assets-controllers/src/TokenBalancesController.test.ts b/packages/assets-controllers/src/TokenBalancesController.test.ts index 47b852af678..e1513829650 100644 --- a/packages/assets-controllers/src/TokenBalancesController.test.ts +++ b/packages/assets-controllers/src/TokenBalancesController.test.ts @@ -15,7 +15,6 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import type nock from 'nock'; -import { useFakeTimers } from 'sinon'; import { mockAPI_accountsAPI_MultichainAccountBalances as mockAPIAccountsAPIMultichainAccountBalancesCamelCase } from './__fixtures__/account-api-v4-mocks'; import * as multicall from './multicall'; @@ -33,7 +32,7 @@ import { parseAssetType, } from './TokenBalancesController'; import type { TokensControllerState } from './TokensController'; -import { advanceTime, flushPromises } from '../../../tests/helpers'; +import { jestAdvanceTime, flushPromises } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import type { RpcEndpoint } from '../../network-controller/src/NetworkController'; @@ -325,10 +324,8 @@ describe('Utility Functions', () => { }); describe('TokenBalancesController', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); // Mock safelyExecuteWithTimeout to execute the operation normally by default mockedSafelyExecuteWithTimeout.mockImplementation( @@ -343,7 +340,7 @@ describe('TokenBalancesController', () => { }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); mockedSafelyExecuteWithTimeout.mockRestore(); jest.restoreAllMocks(); }); @@ -537,11 +534,11 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalled(); expect(pollSpy).not.toHaveBeenCalledTimes(2); - await advanceTime({ clock, duration: interval * 1.5 }); + await jestAdvanceTime({ duration: interval * 1.5 }); expect(pollSpy).toHaveBeenCalledTimes(2); }); @@ -682,7 +679,7 @@ describe('TokenBalancesController', () => { [], ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state.tokenBalances).toStrictEqual({ [accountAddress]: { @@ -759,7 +756,7 @@ describe('TokenBalancesController', () => { [], ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Verify balance was removed expect(updateSpy).toHaveBeenCalledTimes(2); @@ -829,7 +826,7 @@ describe('TokenBalancesController', () => { [], ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(updateSpy).toHaveBeenCalledTimes(2); expect(controller.state.tokenBalances).toStrictEqual({ @@ -909,7 +906,7 @@ describe('TokenBalancesController', () => { [], ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Verify initial balances are still there expect(updateSpy).toHaveBeenCalledTimes(1); // should be called only once when we first updated the balances and not twice @@ -1603,7 +1600,7 @@ describe('TokenBalancesController', () => { messenger.publish('KeyringController:accountRemoved', account.address); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state.tokenBalances).toStrictEqual({ [accountAddress2]: { @@ -2684,13 +2681,13 @@ describe('TokenBalancesController', () => { [], ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Verify updateBalances was called expect(updateBalancesSpy).toHaveBeenCalled(); // Wait a bit more for the catch block to execute - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Verify the error was logged expect(consoleWarnSpy).toHaveBeenCalledWith( @@ -3380,7 +3377,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89'] }); // Initial polls should happen immediately for both chains - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(2); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); @@ -3388,14 +3385,14 @@ describe('TokenBalancesController', () => { pollSpy.mockClear(); // Advance by Ethereum interval (1000ms) - only Ethereum should poll - await advanceTime({ clock, duration: ethInterval }); + await jestAdvanceTime({ duration: ethInterval }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); pollSpy.mockClear(); // Advance by another 1000ms (total 2000ms) - both should poll - await advanceTime({ clock, duration: ethInterval }); + await jestAdvanceTime({ duration: ethInterval }); expect(pollSpy).toHaveBeenCalledTimes(2); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); // Ethereum again expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); // Polygon first repeat @@ -3439,12 +3436,12 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89'] }); // Initial polls - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(2); pollSpy.mockClear(); // Advance 1500ms - only Ethereum should poll - await advanceTime({ clock, duration: ethInterval }); + await jestAdvanceTime({ duration: ethInterval }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); @@ -3456,7 +3453,7 @@ describe('TokenBalancesController', () => { pollSpy.mockClear(); // Advance 1500ms - both should poll now (same interval, grouped together) - await advanceTime({ clock, duration: ethInterval }); + await jestAdvanceTime({ duration: ethInterval }); expect(pollSpy).toHaveBeenCalledTimes(1); // Now grouped together expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0x89'] }); // Both chains in one call @@ -3504,7 +3501,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89', '0xa4b1'] }); // Initial polls - should group efficiently - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(2); // Two groups: fast (ETH + ARB) and slow (MATIC) // Verify Ethereum and Arbitrum are grouped together (same interval) @@ -3515,14 +3512,14 @@ describe('TokenBalancesController', () => { pollSpy.mockClear(); // Advance by fast interval (1200ms) - only fast group should poll - await advanceTime({ clock, duration: fastInterval }); + await jestAdvanceTime({ duration: fastInterval }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0xa4b1'] }); pollSpy.mockClear(); // Advance by another 1200ms (total 2400ms) - both groups should poll - await advanceTime({ clock, duration: fastInterval }); + await jestAdvanceTime({ duration: fastInterval }); expect(pollSpy).toHaveBeenCalledTimes(2); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0xa4b1'] }); // Fast group again expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); // Slow group first repeat @@ -3567,7 +3564,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89'] }); // Initial polls - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(2); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); @@ -3575,14 +3572,14 @@ describe('TokenBalancesController', () => { pollSpy.mockClear(); // Advance 800ms - only Ethereum should poll (configured interval) - await advanceTime({ clock, duration: ethInterval }); + await jestAdvanceTime({ duration: ethInterval }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); pollSpy.mockClear(); // Advance another 800ms (total 1600ms) - both should poll - await advanceTime({ clock, duration: ethInterval }); + await jestAdvanceTime({ duration: ethInterval }); expect(pollSpy).toHaveBeenCalledTimes(2); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); // Ethereum again expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); // Polygon using default interval @@ -3623,12 +3620,12 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89'] }); // Initial polls - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(2); pollSpy.mockClear(); // Let some polling happen - await advanceTime({ clock, duration: 1000 }); // Ethereum polls + await jestAdvanceTime({ duration: 1000 }); // Ethereum polls expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); @@ -3641,7 +3638,7 @@ describe('TokenBalancesController', () => { pollSpy.mockClear(); // Both should now poll every 500ms (regrouped) - await advanceTime({ clock, duration: 500 }); + await jestAdvanceTime({ duration: 500 }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0x89'] }); // Now grouped together @@ -3650,7 +3647,7 @@ describe('TokenBalancesController', () => { it('should preserve original chainIds across config updates even when chains have no tokens', async () => { // Test the design flaw fix: original chainIds should be preserved, not replaced with chainIdsWithTokens - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const tokens = { allTokens: { @@ -3683,7 +3680,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89', '0xa4b1'] }); // Initial polls - all 3 chains should be polled despite only Ethereum having tokens - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(3); // All three chains polled // Verify all originally requested chains are being polled @@ -3700,7 +3697,7 @@ describe('TokenBalancesController', () => { // All originally requested chains should still be polled (not just chains with tokens) // Wait for the longest interval (3000ms) to ensure all interval groups have polled - await advanceTime({ clock: testClock, duration: 3000 }); + await jestAdvanceTime({ duration: 3000 }); // ✅ KEY VERIFICATION: All originally requested chains are still being polled, // including Polygon and Arbitrum which have NO tokens! @@ -3717,12 +3714,12 @@ describe('TokenBalancesController', () => { expect(allCalledChains).toContain('0xa4b1'); // Arbitrum (no tokens) - ✅ PRESERVED! controller.stopAllPolling(); - testClock.restore(); + jest.useRealTimers(); }); it('should preserve original chainIds when tokens are added or removed during polling', async () => { // Test that token changes don't affect original polling intent - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const initialTokens = { allTokens: { @@ -3748,7 +3745,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89', '0xa4b1'] }); // Initial state: all 3 chains polled (they use default interval so grouped together) - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); // All chains use same default interval, so grouped expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0x89', '0xa4b1'], @@ -3779,7 +3776,7 @@ describe('TokenBalancesController', () => { pollSpy.mockClear(); // After token change, should still poll all originally requested chains - await advanceTime({ clock: testClock, duration: 1000 }); + await jestAdvanceTime({ duration: 1000 }); // ✅ KEY VERIFICATION: All originally requested chains are still being polled // even after token state changes (not filtered by chainIdsWithTokens) @@ -3795,12 +3792,12 @@ describe('TokenBalancesController', () => { expect(allCalledChains).toContain('0xa4b1'); // Arbitrum (still no tokens) - ✅ PRESERVED! controller.stopAllPolling(); - testClock.restore(); + jest.useRealTimers(); }); describe('immediateUpdate option', () => { it('should trigger immediate polling by default when updating configs', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const chainId = '0x1'; const accountAddress = '0x0000000000000000000000000000000000000000'; const tokenAddress = '0x0000000000000000000000000000000000000001'; @@ -3829,7 +3826,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: [chainId] }); // Wait for initial poll - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); pollSpy.mockClear(); @@ -3839,22 +3836,22 @@ describe('TokenBalancesController', () => { }); // Should trigger immediate polling by default - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: [chainId] }); pollSpy.mockClear(); // And should continue polling on the new interval - await advanceTime({ clock: testClock, duration: 15000 }); + await jestAdvanceTime({ duration: 15000 }); expect(pollSpy).toHaveBeenCalledTimes(1); controller.stopAllPolling(); - testClock.restore(); + jest.useRealTimers(); }); it('should not trigger immediate polling when immediateUpdate is false', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const chainId = '0x1'; const accountAddress = '0x0000000000000000000000000000000000000000'; const tokenAddress = '0x0000000000000000000000000000000000000001'; @@ -3883,7 +3880,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: [chainId] }); // Wait for initial poll - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); pollSpy.mockClear(); @@ -3899,15 +3896,15 @@ describe('TokenBalancesController', () => { expect(pollSpy).not.toHaveBeenCalled(); // But should poll on the new interval - await advanceTime({ clock: testClock, duration: 15000 }); + await jestAdvanceTime({ duration: 15000 }); expect(pollSpy).toHaveBeenCalledTimes(1); controller.stopAllPolling(); - testClock.restore(); + jest.useRealTimers(); }); it('should trigger immediate polling when immediateUpdate is true', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const chainId = '0x1'; const accountAddress = '0x0000000000000000000000000000000000000000'; const tokenAddress = '0x0000000000000000000000000000000000000001'; @@ -3936,7 +3933,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: [chainId] }); // Wait for initial poll - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); pollSpy.mockClear(); @@ -3949,18 +3946,18 @@ describe('TokenBalancesController', () => { ); // Should trigger immediate polling - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); expect(pollSpy).toHaveBeenCalledWith({ chainIds: [chainId] }); pollSpy.mockClear(); // And should continue polling on the new interval - await advanceTime({ clock: testClock, duration: 15000 }); + await jestAdvanceTime({ duration: 15000 }); expect(pollSpy).toHaveBeenCalledTimes(1); controller.stopAllPolling(); - testClock.restore(); + jest.useRealTimers(); }); it('should handle immediateUpdate option when polling is not active', () => { @@ -4008,7 +4005,7 @@ describe('TokenBalancesController', () => { }); it('should handle immediateUpdate with multiple chains and different intervals', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const accountAddress = '0x0000000000000000000000000000000000000000'; const tokens = { @@ -4040,7 +4037,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1', '0x89'] }); // Wait for initial polls - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(1); // Both chains use default interval pollSpy.mockClear(); @@ -4054,13 +4051,13 @@ describe('TokenBalancesController', () => { ); // Should trigger immediate polling for all chains - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(pollSpy).toHaveBeenCalledTimes(2); // Now different intervals, so separate calls expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x1'] }); expect(pollSpy).toHaveBeenCalledWith({ chainIds: ['0x89'] }); controller.stopAllPolling(); - testClock.restore(); + jest.useRealTimers(); }); }); }); @@ -4097,10 +4094,10 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); // Wait for initial poll and error - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Wait for interval poll and error - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); // Should have attempted polls despite errors expect(pollSpy).toHaveBeenCalledTimes(2); @@ -4163,7 +4160,7 @@ describe('TokenBalancesController', () => { ]); // Wait for async error handling - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(updateBalancesSpy).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith( @@ -4202,7 +4199,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); // Wait for any immediate polling to complete - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Clean up controller.stopAllPolling(); @@ -5645,7 +5642,7 @@ describe('TokenBalancesController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -5657,7 +5654,7 @@ describe('TokenBalancesController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -5670,8 +5667,8 @@ describe('TokenBalancesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "tokenBalances": Object {}, + { + "tokenBalances": {}, } `); }); @@ -5686,8 +5683,8 @@ describe('TokenBalancesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "tokenBalances": Object {}, + { + "tokenBalances": {}, } `); }); @@ -5702,7 +5699,7 @@ describe('TokenBalancesController', () => { chainId: '0x1', } as unknown as TransactionMeta); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(updateBalancesSpy).toHaveBeenCalledWith({ chainIds: ['0x1'], @@ -5718,7 +5715,7 @@ describe('TokenBalancesController', () => { { chainId: '0x89' }, ] as unknown as TransactionMeta[]); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(updateBalancesSpy).toHaveBeenCalledWith({ chainIds: ['0x1', '0x89'], @@ -5748,7 +5745,7 @@ describe('TokenBalancesController', () => { [], ); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(warnSpy).toHaveBeenCalledWith( 'Error updating balances after token change:', @@ -5778,7 +5775,7 @@ describe('TokenBalancesController', () => { updates: BalanceUpdate[]; }); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Error handling balance update:'), @@ -5804,7 +5801,7 @@ describe('TokenBalancesController', () => { controller.stopAllPolling(); // Wait for poll interval - await clock.tickAsync(2000); + await jest.advanceTimersByTimeAsync(2000); // updateBalances should have been called once during startPolling, // but not again after stopping @@ -5863,7 +5860,7 @@ describe('TokenBalancesController', () => { [], ); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); // updateBalances should not be called since tokens haven't changed expect(updateBalancesSpy).not.toHaveBeenCalled(); @@ -5882,7 +5879,7 @@ describe('TokenBalancesController', () => { }); // Wait for debounce - await clock.tickAsync(6000); + await jest.advanceTimersByTimeAsync(6000); // No errors should occur and controller should still be functional expect(controller.state.tokenBalances).toBeDefined(); @@ -5921,7 +5918,7 @@ describe('TokenBalancesController', () => { [], ); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(consoleWarnSpy).toHaveBeenCalledWith( 'Error handling token state change:', @@ -5946,7 +5943,7 @@ describe('TokenBalancesController', () => { updates: [], }); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Error'), @@ -5971,7 +5968,7 @@ describe('TokenBalancesController', () => { controller.stopAllPolling(); // Polling should not execute when inactive - await clock.tickAsync(35000); + await jest.advanceTimersByTimeAsync(35000); // Controller state should remain unchanged expect(controller.state.tokenBalances).toBeDefined(); @@ -5990,12 +5987,12 @@ describe('TokenBalancesController', () => { // Start polling twice with same chain - should clear previous timer controller.startPolling({ chainIds: ['0x1'] }); - await clock.tickAsync(100); + await jest.advanceTimersByTimeAsync(100); controller.startPolling({ chainIds: ['0x1'] }); // Should not cause double polling - await clock.tickAsync(35000); + await jest.advanceTimersByTimeAsync(35000); expect(controller.state.tokenBalances).toBeDefined(); }); @@ -6046,7 +6043,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); - await clock.tickAsync(100); + await jest.advanceTimersByTimeAsync(100); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Polling failed'), @@ -6091,7 +6088,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); - await clock.tickAsync(100); + await jest.advanceTimersByTimeAsync(100); // Now break the handler to cause errors on subsequent polls // Breaking AccountsController:getSelectedAccount causes error before #fetchAllBalances @@ -6106,7 +6103,7 @@ describe('TokenBalancesController', () => { ); // Wait for interval polling to trigger - await clock.tickAsync(1500); + await jest.advanceTimersByTimeAsync(1500); expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Polling failed'), @@ -6312,7 +6309,7 @@ describe('TokenBalancesController', () => { }); it('should clear timers during interval group polling restart (line 620 path)', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); @@ -6322,7 +6319,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); // Wait for initial poll - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Start polling again - this goes through #startIntervalGroupPolling // which clears existing timers at line 564 @@ -6333,11 +6330,11 @@ describe('TokenBalancesController', () => { controller.stopAllPolling(); clearIntervalSpy.mockRestore(); - testClock.restore(); + jest.useRealTimers(); }); it('should log warning when interval polling fails (line 625)', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const consoleWarnSpy = jest .spyOn(console, 'warn') @@ -6355,7 +6352,7 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); // Advance timer to trigger the interval callback - await advanceTime({ clock: testClock, duration: 35000 }); + await jestAdvanceTime({ duration: 35000 }); // Wait for the promise to reject await flushPromises(); @@ -6366,7 +6363,7 @@ describe('TokenBalancesController', () => { controller.stopAllPolling(); multicallSpy.mockRestore(); consoleWarnSpy.mockRestore(); - testClock.restore(); + jest.useRealTimers(); }); it('should filter balances by token addresses when provided (lines 904-906)', async () => { @@ -6567,7 +6564,7 @@ describe('TokenBalancesController', () => { }); it('should return early from poll function when controller is inactive (line 588)', async () => { - const testClock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const { controller, messenger } = setupController(); @@ -6579,21 +6576,21 @@ describe('TokenBalancesController', () => { controller.startPolling({ chainIds: ['0x1'] }); // Wait for immediate poll - await advanceTime({ clock: testClock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const initialCallCount = multicallSpy.mock.calls.length; // Lock the controller (sets #isControllerPollingActive to false) messenger.publish('KeyringController:lock'); // Advance time to trigger the interval poll - await advanceTime({ clock: testClock, duration: 35000 }); + await jestAdvanceTime({ duration: 35000 }); // The poll function should have returned early without calling multicall expect(multicallSpy.mock.calls).toHaveLength(initialCallCount); controller.stopAllPolling(); multicallSpy.mockRestore(); - testClock.restore(); + jest.useRealTimers(); }); it('should log warning when poll execution fails (line 603)', async () => { diff --git a/packages/assets-controllers/src/TokenDetectionController.test.ts b/packages/assets-controllers/src/TokenDetectionController.test.ts index c2aeba4993a..10cf3062893 100644 --- a/packages/assets-controllers/src/TokenDetectionController.test.ts +++ b/packages/assets-controllers/src/TokenDetectionController.test.ts @@ -29,7 +29,6 @@ import type { PreferencesState } from '@metamask/preferences-controller'; import type { Hex } from '@metamask/utils'; import BN from 'bn.js'; import nock from 'nock'; -import sinon from 'sinon'; import { formatAggregatorNames } from './assetsUtil'; import { TOKEN_END_POINT_API } from './token-service'; @@ -47,7 +46,7 @@ import type { TokensControllerState, } from './TokensController'; import { getDefaultTokensState } from './TokensController'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; import { createMockInternalAccount } from '../../accounts-controller/src/tests/mocks'; import { buildCustomRpcEndpoint, @@ -246,18 +245,13 @@ describe('TokenDetectionController', () => { .persist(); }); - afterEach(() => { - sinon.restore(); - }); - describe('start', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('should not poll and detect tokens on interval while keyring is locked', async () => { @@ -270,14 +264,16 @@ describe('TokenDetectionController', () => { }, }, async ({ controller }) => { - const mockTokens = sinon.stub(controller, 'detectTokens'); + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); controller.setIntervalLength(10); await controller.start(); - expect(mockTokens.calledOnce).toBe(false); - await advanceTime({ clock, duration: 15 }); - expect(mockTokens.calledTwice).toBe(false); + expect(mockTokens).not.toHaveBeenCalled(); + await jestAdvanceTime({ duration: 15 }); + expect(mockTokens).not.toHaveBeenCalled(); }, ); }); @@ -292,13 +288,15 @@ describe('TokenDetectionController', () => { }, }, async ({ controller, triggerKeyringUnlock }) => { - const mockTokens = sinon.stub(controller, 'detectTokens'); + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); await controller.start(); triggerKeyringUnlock(); - await advanceTime({ clock, duration: DEFAULT_INTERVAL * 1.5 }); - expect(mockTokens.calledTwice).toBe(false); + await jestAdvanceTime({ duration: DEFAULT_INTERVAL * 1.5 }); + expect(mockTokens).not.toHaveBeenCalledTimes(2); }, ); }); @@ -327,15 +325,17 @@ describe('TokenDetectionController', () => { isKeyringUnlocked: true, }, async ({ controller, triggerKeyringLock }) => { - const mockTokens = sinon.stub(controller, 'detectTokens'); + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); controller.setIntervalLength(10); await controller.start(); triggerKeyringLock(); - expect(mockTokens.calledOnce).toBe(true); - await advanceTime({ clock, duration: 15 }); - expect(mockTokens.calledTwice).toBe(false); + expect(mockTokens).toHaveBeenCalledTimes(1); + await jestAdvanceTime({ duration: 15 }); + expect(mockTokens).toHaveBeenCalledTimes(1); }, ); }); @@ -349,14 +349,16 @@ describe('TokenDetectionController', () => { }, }, async ({ controller }) => { - const mockTokens = sinon.stub(controller, 'detectTokens'); + const mockTokens = jest + .spyOn(controller, 'detectTokens') + .mockImplementation(); controller.setIntervalLength(10); await controller.start(); - expect(mockTokens.calledOnce).toBe(true); - await advanceTime({ clock, duration: 15 }); - expect(mockTokens.calledTwice).toBe(true); + expect(mockTokens).toHaveBeenCalledTimes(1); + await jestAdvanceTime({ duration: 15 }); + expect(mockTokens).toHaveBeenCalledTimes(2); }, ); }); @@ -647,7 +649,7 @@ describe('TokenDetectionController', () => { iconUrl: sampleTokenB.image, }; mockTokenListGetState(tokenListState); - await advanceTime({ clock, duration: interval }); + await jestAdvanceTime({ duration: interval }); expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addTokens', @@ -758,13 +760,12 @@ describe('TokenDetectionController', () => { }); describe('AccountsController:selectedAccountChange', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('when "disabled" is false', () => { @@ -827,7 +828,7 @@ describe('TokenDetectionController', () => { mockGetAccount(secondSelectedAccount); triggerSelectedAccountChange(secondSelectedAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addTokens', @@ -883,7 +884,7 @@ describe('TokenDetectionController', () => { triggerSelectedAccountChange({ address: selectedAccount.address, } as InternalAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -942,7 +943,7 @@ describe('TokenDetectionController', () => { triggerSelectedAccountChange({ address: secondSelectedAccount.address, } as InternalAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1002,7 +1003,7 @@ describe('TokenDetectionController', () => { triggerSelectedAccountChange({ address: secondSelectedAccount.address, } as InternalAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1014,13 +1015,12 @@ describe('TokenDetectionController', () => { }); describe('PreferencesController:stateChange', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('when "disabled" is false', () => { @@ -1098,7 +1098,7 @@ describe('TokenDetectionController', () => { }); mockGetAccount(secondSelectedAccount); triggerSelectedAccountChange(secondSelectedAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).toHaveBeenLastCalledWith( 'TokensController:addTokens', @@ -1170,7 +1170,7 @@ describe('TokenDetectionController', () => { mockGetAccount(secondSelectedAccount); triggerSelectedAccountChange(secondSelectedAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // detectTokens is called once when account changes // (preference change doesn't trigger since useTokenDetection was already true by default) @@ -1233,13 +1233,13 @@ describe('TokenDetectionController', () => { ...getDefaultPreferencesState(), useTokenDetection: false, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addTokens', @@ -1304,7 +1304,7 @@ describe('TokenDetectionController', () => { }); mockGetAccount(secondSelectedAccount); triggerSelectedAccountChange(secondSelectedAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1360,7 +1360,7 @@ describe('TokenDetectionController', () => { ...getDefaultPreferencesState(), useTokenDetection: true, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1426,7 +1426,7 @@ describe('TokenDetectionController', () => { }); mockGetAccount(secondSelectedAccount); triggerSelectedAccountChange(secondSelectedAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1483,13 +1483,13 @@ describe('TokenDetectionController', () => { ...getDefaultPreferencesState(), useTokenDetection: false, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1554,7 +1554,7 @@ describe('TokenDetectionController', () => { }); mockGetAccount(secondSelectedAccount); triggerSelectedAccountChange(secondSelectedAccount); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1610,13 +1610,13 @@ describe('TokenDetectionController', () => { ...getDefaultPreferencesState(), useTokenDetection: false, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); triggerPreferencesStateChange({ ...getDefaultPreferencesState(), useTokenDetection: true, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1628,13 +1628,12 @@ describe('TokenDetectionController', () => { }); describe('NetworkController:networkDidChange', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('when "disabled" is false', () => { @@ -1685,7 +1684,7 @@ describe('TokenDetectionController', () => { ...getDefaultNetworkControllerState(), selectedNetworkClientId: NetworkType.sepolia, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1741,7 +1740,7 @@ describe('TokenDetectionController', () => { ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1799,7 +1798,7 @@ describe('TokenDetectionController', () => { ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1858,7 +1857,7 @@ describe('TokenDetectionController', () => { ...getDefaultNetworkControllerState(), selectedNetworkClientId: 'avalanche', }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -1870,13 +1869,12 @@ describe('TokenDetectionController', () => { }); describe('TokenListController:stateChange', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('when "disabled" is false', () => { @@ -1932,7 +1930,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).toHaveBeenCalledWith( 'TokensController:addTokens', @@ -1973,7 +1971,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -2029,7 +2027,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -2086,7 +2084,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(callActionSpy).not.toHaveBeenCalledWith( 'TokensController:addDetectedTokens', @@ -2142,13 +2140,13 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); // This should set the tokensChainsCache value triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const mockTokens = jest.spyOn(controller, 'detectTokens'); // Re-trigger state change so that incoming list is equal the current list in state triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(mockTokens).toHaveBeenCalledTimes(0); }, ); @@ -2201,7 +2199,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); // This should set the tokensChainsCache value triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const mockTokens = jest.spyOn(controller, 'detectTokens'); @@ -2225,7 +2223,7 @@ describe('TokenDetectionController', () => { }, }, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(mockTokens).toHaveBeenCalledTimes(0); }, ); @@ -2278,7 +2276,7 @@ describe('TokenDetectionController', () => { mockTokenListGetState(tokenListState); // This should set the tokensChainsCache value triggerTokenListStateChange(tokenListState); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const mockTokens = jest.spyOn(controller, 'detectTokens'); @@ -2303,7 +2301,7 @@ describe('TokenDetectionController', () => { }, }, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(mockTokens).toHaveBeenCalledTimes(1); }, ); @@ -2312,13 +2310,12 @@ describe('TokenDetectionController', () => { }); describe('startPolling', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('should call detect tokens with networkClientId and address params', async () => { @@ -2377,7 +2374,7 @@ describe('TokenDetectionController', () => { chainIds: ['0x5'], address: '0x3', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(spy.mock.calls).toMatchObject([ [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], @@ -2385,7 +2382,7 @@ describe('TokenDetectionController', () => { [{ chainIds: ['0x5'], selectedAddress: '0x3' }], ]); - await advanceTime({ clock, duration: DEFAULT_INTERVAL }); + await jestAdvanceTime({ duration: DEFAULT_INTERVAL }); expect(spy.mock.calls).toMatchObject([ [{ chainIds: ['0xa86a'], selectedAddress: '0x1' }], [{ chainIds: ['0xa86a'], selectedAddress: '0xdeadbeef' }], diff --git a/packages/assets-controllers/src/TokenListController.test.ts b/packages/assets-controllers/src/TokenListController.test.ts index 3f557dfe36c..af869322f22 100644 --- a/packages/assets-controllers/src/TokenListController.test.ts +++ b/packages/assets-controllers/src/TokenListController.test.ts @@ -15,7 +15,6 @@ import type { import type { NetworkState } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import nock from 'nock'; -import * as sinon from 'sinon'; import * as tokenService from './token-service'; import type { @@ -25,7 +24,7 @@ import type { DataCache, } from './TokenListController'; import { TokenListController } from './TokenListController'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; import { buildCustomNetworkClientConfiguration, buildInfuraNetworkClientConfiguration, @@ -555,7 +554,6 @@ describe('TokenListController', () => { afterEach(() => { jest.clearAllTimers(); - sinon.restore(); }); it('should set default state', async () => { @@ -750,10 +748,9 @@ describe('TokenListController', () => { }); it('should poll and update rate in the right interval', async () => { - const tokenListMock = sinon.stub( - TokenListController.prototype, - 'fetchTokenList', - ); + const tokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); @@ -765,19 +762,18 @@ describe('TokenListController', () => { await controller.start(); await new Promise((resolve) => setTimeout(() => resolve(), 1)); - expect(tokenListMock.called).toBe(true); - expect(tokenListMock.calledTwice).toBe(false); + expect(tokenListMock).toHaveBeenCalled(); + expect(tokenListMock).toHaveBeenCalledTimes(1); await new Promise((resolve) => setTimeout(() => resolve(), 150)); - expect(tokenListMock.calledTwice).toBe(true); + expect(tokenListMock).toHaveBeenCalledTimes(2); controller.destroy(); }); it('should not poll after being stopped', async () => { - const tokenListMock = sinon.stub( - TokenListController.prototype, - 'fetchTokenList', - ); + const tokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); @@ -790,20 +786,19 @@ describe('TokenListController', () => { controller.stop(); // called once upon initial start - expect(tokenListMock.called).toBe(true); - expect(tokenListMock.calledTwice).toBe(false); + expect(tokenListMock).toHaveBeenCalled(); + expect(tokenListMock).toHaveBeenCalledTimes(1); await new Promise((resolve) => setTimeout(() => resolve(), 150)); - expect(tokenListMock.calledTwice).toBe(false); + expect(tokenListMock).toHaveBeenCalledTimes(1); controller.destroy(); }); it('should poll correctly after being started, stopped, and started again', async () => { - const tokenListMock = sinon.stub( - TokenListController.prototype, - 'fetchTokenList', - ); + const tokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); @@ -817,23 +812,22 @@ describe('TokenListController', () => { controller.stop(); // called once upon initial start - expect(tokenListMock.called).toBe(true); - expect(tokenListMock.calledTwice).toBe(false); + expect(tokenListMock).toHaveBeenCalled(); + expect(tokenListMock).toHaveBeenCalledTimes(1); await controller.start(); await new Promise((resolve) => setTimeout(() => resolve(), 1)); - expect(tokenListMock.calledTwice).toBe(true); + expect(tokenListMock).toHaveBeenCalledTimes(2); await new Promise((resolve) => setTimeout(() => resolve(), 150)); - expect(tokenListMock.calledThrice).toBe(true); + expect(tokenListMock).toHaveBeenCalledTimes(3); controller.destroy(); }); it('should call fetchTokenList on network that supports token detection', async () => { - const tokenListMock = sinon.stub( - TokenListController.prototype, - 'fetchTokenList', - ); + const tokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); @@ -846,15 +840,14 @@ describe('TokenListController', () => { controller.stop(); // called once upon initial start - expect(tokenListMock.called).toBe(true); + expect(tokenListMock).toHaveBeenCalled(); controller.destroy(); }); it('should not call fetchTokenList on network that does not support token detection', async () => { - const tokenListMock = sinon.stub( - TokenListController.prototype, - 'fetchTokenList', - ); + const tokenListMock = jest + .spyOn(TokenListController.prototype, 'fetchTokenList') + .mockImplementation(); const messenger = getMessenger(); const restrictedMessenger = getRestrictedMessenger(messenger); @@ -867,10 +860,9 @@ describe('TokenListController', () => { controller.stop(); // called once upon initial start - expect(tokenListMock.called).toBe(false); + expect(tokenListMock).not.toHaveBeenCalled(); controller.destroy(); - tokenListMock.restore(); }); it('should update tokensChainsCache from api', async () => { @@ -1078,14 +1070,13 @@ describe('TokenListController', () => { }); describe('startPolling', () => { - let clock: sinon.SinonFakeTimers; const pollingIntervalTime = 1000; beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('should call fetchTokenListByChainId with the correct chainId', async () => { @@ -1117,7 +1108,7 @@ describe('TokenListController', () => { }); controller.startPolling({ chainId: ChainId.sepolia }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(fetchTokenListByChainIdSpy.mock.calls[0]).toStrictEqual( expect.arrayContaining([ChainId.sepolia]), @@ -1182,7 +1173,7 @@ describe('TokenListController', () => { }); // wait a polling interval - await advanceTime({ clock, duration: pollingIntervalTime }); + await jestAdvanceTime({ duration: pollingIntervalTime }); expect(fetchTokenListByChainIdSpy).toHaveBeenCalledTimes(1); @@ -1198,7 +1189,7 @@ describe('TokenListController', () => { controller.startPolling({ chainId: '0x38', }); - await advanceTime({ clock, duration: pollingIntervalTime }); + await jestAdvanceTime({ duration: pollingIntervalTime }); // expect fetchTokenListByChain to be called for binance, but not for sepolia // because the cache for the recently fetched sepolia token list is still valid @@ -1233,8 +1224,8 @@ describe('TokenListController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "tokensChainsCache": Object {}, + { + "tokensChainsCache": {}, } `); }); @@ -1251,7 +1242,7 @@ describe('TokenListController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -1266,7 +1257,7 @@ describe('TokenListController', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -1282,8 +1273,8 @@ describe('TokenListController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "tokensChainsCache": Object {}, + { + "tokensChainsCache": {}, } `); }); diff --git a/packages/assets-controllers/src/TokenRatesController.test.ts b/packages/assets-controllers/src/TokenRatesController.test.ts index 761657d12dd..b7bf20d5c84 100644 --- a/packages/assets-controllers/src/TokenRatesController.test.ts +++ b/packages/assets-controllers/src/TokenRatesController.test.ts @@ -1142,7 +1142,7 @@ describe('TokenRatesController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -1154,7 +1154,7 @@ describe('TokenRatesController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -1167,8 +1167,8 @@ describe('TokenRatesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "marketData": Object {}, + { + "marketData": {}, } `); }); @@ -1183,8 +1183,8 @@ describe('TokenRatesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "marketData": Object {}, + { + "marketData": {}, } `); }); @@ -1343,8 +1343,11 @@ async function fetchTokenPricesWithIncreasingPriceForEachToken< return assets.map(({ tokenAddress, chainId }, i) => ({ tokenAddress, chainId, - assetId: - `${KnownCaipNamespace.Eip155}:1/${tokenAddress === ZERO_ADDRESS ? 'slip44:60' : `erc20:${tokenAddress.toLowerCase()}`}` as CaipAssetType, + assetId: `${KnownCaipNamespace.Eip155}:1/${ + tokenAddress === ZERO_ADDRESS + ? 'slip44:60' + : `erc20:${tokenAddress.toLowerCase()}` + }` as CaipAssetType, currency, pricePercentChange1d: 0, priceChange1d: 0, diff --git a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts index 7c7e8fdf570..f2a805f7490 100644 --- a/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts +++ b/packages/assets-controllers/src/TokenSearchDiscoveryDataController/TokenSearchDiscoveryDataController.test.ts @@ -711,7 +711,7 @@ describe('TokenSearchDiscoveryDataController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -723,7 +723,7 @@ describe('TokenSearchDiscoveryDataController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -736,8 +736,8 @@ describe('TokenSearchDiscoveryDataController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "tokenDisplayData": Array [], + { + "tokenDisplayData": [], } `); }); @@ -752,8 +752,8 @@ describe('TokenSearchDiscoveryDataController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "tokenDisplayData": Array [], + { + "tokenDisplayData": [], } `); }); diff --git a/packages/assets-controllers/src/TokensController.test.ts b/packages/assets-controllers/src/TokensController.test.ts index a552e4904c6..3532a49a3f8 100644 --- a/packages/assets-controllers/src/TokensController.test.ts +++ b/packages/assets-controllers/src/TokensController.test.ts @@ -28,7 +28,6 @@ import type { import { getDefaultNetworkControllerState } from '@metamask/network-controller'; import type { Patch } from 'immer'; import nock from 'nock'; -import * as sinon from 'sinon'; import { v1 as uuidV1 } from 'uuid'; import { ERC20Standard } from './Standards/ERC20Standard'; @@ -83,10 +82,6 @@ describe('TokensController', () => { ); }); - afterEach(() => { - sinon.restore(); - }); - it('should set default state', async () => { await withController(({ controller }) => { expect(controller.state).toStrictEqual({ @@ -3774,7 +3769,7 @@ describe('TokensController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -3786,7 +3781,7 @@ describe('TokensController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -3799,10 +3794,10 @@ describe('TokensController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "allDetectedTokens": Object {}, - "allIgnoredTokens": Object {}, - "allTokens": Object {}, + { + "allDetectedTokens": {}, + "allIgnoredTokens": {}, + "allTokens": {}, } `); }); @@ -3817,10 +3812,10 @@ describe('TokensController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "allDetectedTokens": Object {}, - "allIgnoredTokens": Object {}, - "allTokens": Object {}, + { + "allDetectedTokens": {}, + "allIgnoredTokens": {}, + "allTokens": {}, } `); }); diff --git a/packages/assets-controllers/src/multicall.ts b/packages/assets-controllers/src/multicall.ts index 91d2240d6b2..d1153f14af5 100644 --- a/packages/assets-controllers/src/multicall.ts +++ b/packages/assets-controllers/src/multicall.ts @@ -307,6 +307,8 @@ const MULTICALL_CONTRACT_BY_CHAINID = { '0x10e6': '0xcA11bde05977b3631167028862bE2a173976CA11', // MSU (contract they deployed by their team for us) '0x10b3e': '0x99423C88EB5723A590b4C644426069042f137B9e', + // INK Mainnet + '0xdef1': '0xcA11bde05977b3631167028862bE2a173976CA11', } as Record; const multicallAbi = [ 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 18085643b66..ad25d83a78b 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.test.ts @@ -1,7 +1,6 @@ import { KnownCaipNamespace } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import nock, { isDone } from 'nock'; -import { useFakeTimers } from 'sinon'; import { CodefiTokenPricesServiceV2, @@ -24,14 +23,15 @@ const defaultMaxRetryDelay = 30_000; describe('CodefiTokenPricesServiceV2', () => { describe('onBreak', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('registers a listener that is called upon break', async () => { @@ -155,10 +155,8 @@ describe('CodefiTokenPricesServiceV2', () => { // 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, }), @@ -170,14 +168,15 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('onDegraded', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('calls onDegraded when request is slower than threshold', async () => { @@ -219,7 +218,6 @@ describe('CodefiTokenPricesServiceV2', () => { service.onDegraded(onDegradedHandler); await fetchTokenPricesWithFakeTimers({ - clock, fetchTokenPrices: () => service.fetchTokenPrices({ assets: [ @@ -1021,14 +1019,15 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('before circuit break', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('calls onDegraded when request is slower than threshold', async () => { @@ -1070,7 +1069,6 @@ describe('CodefiTokenPricesServiceV2', () => { }); await fetchTokenPricesWithFakeTimers({ - clock, fetchTokenPrices: () => service.fetchTokenPrices({ assets: [ @@ -1097,14 +1095,15 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('after circuit break', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('calls onBreak handler upon break', async () => { @@ -1228,10 +1227,8 @@ describe('CodefiTokenPricesServiceV2', () => { // 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, }), @@ -1653,14 +1650,15 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('before circuit break', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('calls onDegraded when request is slower than threshold', async () => { @@ -1681,7 +1679,6 @@ describe('CodefiTokenPricesServiceV2', () => { }); await fetchExchangeRatesWithFakeTimers({ - clock, fetchExchangeRates: () => service.fetchExchangeRates({ baseCurrency: 'eur', @@ -1696,14 +1693,15 @@ describe('CodefiTokenPricesServiceV2', () => { }); describe('after circuit break', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers({ now: Date.now() }); + jest.useFakeTimers({ + now: Date.now(), + doNotFake: ['nextTick', 'queueMicrotask'], + }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('calls onBreak handler upon break', async () => { @@ -1747,10 +1745,8 @@ describe('CodefiTokenPricesServiceV2', () => { // 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(() => fetchExchangeRatesWithFakeTimers({ - clock, fetchExchangeRates, retries, }), @@ -2200,17 +2196,14 @@ describe('CodefiTokenPricesServiceV2', () => { * resolves. * * @param args - Arguments - * @param args.clock - The fake timers clock to advance. * @param args.fetchTokenPrices - The "fetchTokenPrices" function to call. * @param args.retries - The number of retries the fetch call is configured to make. * @returns The result of the fetch call. */ async function fetchTokenPricesWithFakeTimers({ - clock, fetchTokenPrices, retries, }: { - clock: sinon.SinonFakeTimers; fetchTokenPrices: () => ReturnType< CodefiTokenPricesServiceV2['fetchTokenPrices'] >; @@ -2225,7 +2218,7 @@ async function fetchTokenPricesWithFakeTimers({ // subsequent retries // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const _retryAttempt of Array(retries + 1).keys()) { - await clock.tickAsync(defaultMaxRetryDelay); + await jest.advanceTimersByTimeAsync(defaultMaxRetryDelay); } return await pendingUpdate; @@ -2242,17 +2235,14 @@ async function fetchTokenPricesWithFakeTimers({ * resolves. * * @param args - Arguments - * @param args.clock - The fake timers clock to advance. * @param args.fetchExchangeRates - The "fetchExchangeRates" function to call. * @param args.retries - The number of retries the fetch call is configured to make. * @returns The result of the fetch call. */ async function fetchExchangeRatesWithFakeTimers({ - clock, fetchExchangeRates, retries, }: { - clock: sinon.SinonFakeTimers; fetchExchangeRates: () => ReturnType< CodefiTokenPricesServiceV2['fetchExchangeRates'] >; @@ -2267,7 +2257,7 @@ async function fetchExchangeRatesWithFakeTimers({ // subsequent retries // eslint-disable-next-line @typescript-eslint/no-unused-vars for (const _retryAttempt of Array(retries + 1).keys()) { - await clock.tickAsync(defaultMaxRetryDelay); + await jest.advanceTimersByTimeAsync(defaultMaxRetryDelay); } return await pendingUpdate; 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 ea7d4f4272c..d72c8d549c8 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -232,7 +232,6 @@ const chainIdToNativeTokenAddress: Record = { '0x1388': '0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle '0x64': '0xe91d153e0b41518a2ce8dd3d7944fa863463a97d', // Gnosis '0x1e': '0x542fda317318ebf1d3deaf76e0b632741a7e677d', // Rootstock Mainnet - Native symbol: RBTC - '0x2611': '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', // Plasma mainnet - native symbol: XPL }; /** @@ -289,7 +288,7 @@ export const SPOT_PRICES_SUPPORT_INFO = { '0x10e6': 'eip155:4326/erc20:0x0000000000000000000000000000000000000000', // MegaETH Mainnet - Native symbol: ETH '0x1388': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // Mantle - Native symbol: MNT '0x2105': 'eip155:8453/slip44:60', // Base - Native symbol: ETH - '0x2611': 'eip155:9745/erc20:0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', // Plasma mainnet - native symbol: XPL + '0x2611': 'eip155:9745/erc20:0x0000000000000000000000000000000000000000', // Plasma mainnet - native symbol: XPL '0x2710': 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH '0x8173': 'eip155:33139/erc20:0x0000000000000000000000000000000000000000', // Apechain Mainnet - Native symbol: APE '0xa3c3': 'eip155:41923/erc20:0x0000000000000000000000000000000000000000', // EDU Chain - Native symbol: EDU @@ -302,6 +301,7 @@ export const SPOT_PRICES_SUPPORT_INFO = { '0xed88': 'eip155:60808/erc20:0x0000000000000000000000000000000000000000', // BOB - Native symbol: ETH '0x138de': 'eip155:80094/erc20:0x0000000000000000000000000000000000000000', // Berachain - Native symbol: Bera', '0x13c31': 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH + '0x15b38': 'eip155:88888/erc20:0x0000000000000000000000000000000000000000', // Chiliz Chain - Native symbol: CHZ '0x17dcd': 'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // Pepe Unchained Mainnet - Native symbol: PEPU '0x18232': 'eip155:98866/erc20:0x0000000000000000000000000000000000000000', // Plume Mainnet - Narive symbol: Plume '0x28c58': 'eip155:167000/slip44:60', // Taiko Mainnet - Native symbol: ETH @@ -311,6 +311,7 @@ export const SPOT_PRICES_SUPPORT_INFO = { '0x15f900': 'eip155:1440000/erc20:0x0000000000000000000000000000000000000000', // xrpl-evm - native symbol: XRP '0x4e454152': 'eip155:1313161554/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH '0x63564c40': 'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE + '0xdef1': 'eip155:57073/erc20:0x0000000000000000000000000000000000000000', // Ink Mainnet - Native symbol: ETH } as const; // MISSING CHAINS WITH NO NATIVE ASSET PRICES diff --git a/packages/assets-controllers/src/token-prices-service/index.test.ts b/packages/assets-controllers/src/token-prices-service/index.test.ts index 13ffb135df6..28066404bd1 100644 --- a/packages/assets-controllers/src/token-prices-service/index.test.ts +++ b/packages/assets-controllers/src/token-prices-service/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('token-prices-service', () => { it('has expected exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "CodefiTokenPricesServiceV2", "SUPPORTED_CHAIN_IDS", "getNativeTokenAddress", diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 87ba60d35eb..63e4d39179b 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -637,7 +637,7 @@ describe('Token service', () => { }); }); - it('should return empty array if the fetch fails with a network error', async () => { + it('should return empty array with error if the fetch fails with a network error', async () => { const searchQuery = 'USD'; nock(TOKEN_END_POINT_API) .get( @@ -648,10 +648,14 @@ describe('Token service', () => { const result = await searchTokens([sampleCaipChainId], searchQuery); - expect(result).toStrictEqual({ count: 0, data: [] }); + expect(result).toStrictEqual({ + count: 0, + data: [], + error: expect.stringContaining('Example network error'), + }); }); - it('should return empty array if the fetch fails with 400 error', async () => { + it('should return empty array with error if the fetch fails with 400 error', async () => { const searchQuery = 'USD'; nock(TOKEN_END_POINT_API) .get( @@ -662,10 +666,14 @@ describe('Token service', () => { const result = await searchTokens([sampleCaipChainId], searchQuery); - expect(result).toStrictEqual({ count: 0, data: [] }); + expect(result).toStrictEqual({ + count: 0, + data: [], + error: expect.stringContaining("Fetch failed with status '400'"), + }); }); - it('should return empty array if the fetch fails with 500 error', async () => { + it('should return empty array with error if the fetch fails with 500 error', async () => { const searchQuery = 'USD'; nock(TOKEN_END_POINT_API) .get( @@ -676,7 +684,35 @@ describe('Token service', () => { const result = await searchTokens([sampleCaipChainId], searchQuery); - expect(result).toStrictEqual({ count: 0, data: [] }); + expect(result).toStrictEqual({ + count: 0, + data: [], + error: expect.stringContaining("Fetch failed with status '500'"), + }); + }); + + it('should return error for malformed API response', async () => { + const searchQuery = 'USD'; + const malformedResponse = { + count: 5, + // Missing 'data' array - this is malformed + someOtherField: 'value', + }; + + nock(TOKEN_END_POINT_API) + .get( + `/tokens/search?networks=${encodeURIComponent(sampleCaipChainId)}&query=${searchQuery}&limit=10&includeMarketData=false&includeRwaData=true`, + ) + .reply(200, malformedResponse) + .persist(); + + const result = await searchTokens([sampleCaipChainId], searchQuery); + + expect(result).toStrictEqual({ + count: 0, + data: [], + error: 'Unexpected API response format', + }); }); it('should handle empty search results', async () => { @@ -731,8 +767,12 @@ describe('Token service', () => { const result = await searchTokens([sampleCaipChainId], searchQuery); - // Non-array responses should be converted to empty object with count 0 - expect(result).toStrictEqual({ count: 0, data: [] }); + // Non-array responses should be converted to empty object with count 0 and error message + expect(result).toStrictEqual({ + count: 0, + data: [], + error: 'Unexpected API response format', + }); }); it('should handle supported CAIP format chain IDs', async () => { diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index f76529fa8e1..fd41ddee360 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -204,7 +204,7 @@ type SearchTokenOptions = { * @param options.limit - The maximum number of results to return. * @param options.includeMarketData - Optional flag to include market data in the results (defaults to false). * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). - * @returns Object containing count and data array. Returns { count: 0, data: [] } if request fails. + * @returns Object containing count, data array, and an optional error message if the request failed. */ export async function searchTokens( chainIds: CaipChainId[], @@ -214,7 +214,7 @@ export async function searchTokens( includeMarketData = false, includeRwaData = true, }: SearchTokenOptions = {}, -): Promise<{ count: number; data: TokenSearchItem[] }> { +): Promise<{ count: number; data: TokenSearchItem[]; error?: string }> { const tokenSearchURL = getTokenSearchURL({ chainIds, query, @@ -236,11 +236,10 @@ export async function searchTokens( } // Handle non-expected responses - return { count: 0, data: [] }; + return { count: 0, data: [], error: 'Unexpected API response format' }; } catch (error) { - // Handle 400 errors and other failures by returning count 0 and empty array - console.log('Search request failed:', error); - return { count: 0, data: [] }; + const errorMessage = error instanceof Error ? error.message : String(error); + return { count: 0, data: [], error: errorMessage }; } } diff --git a/packages/base-controller/package.json b/packages/base-controller/package.json index bfc855c00e5..22ac39bf5b3 100644 --- a/packages/base-controller/package.json +++ b/packages/base-controller/package.json @@ -55,13 +55,11 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/json-rpc-engine": "^10.2.2", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", - "@types/sinon": "^9.0.10", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/base-controller/src/BaseController.test.ts b/packages/base-controller/src/BaseController.test.ts index ec98950c93b..08008e5f12a 100644 --- a/packages/base-controller/src/BaseController.test.ts +++ b/packages/base-controller/src/BaseController.test.ts @@ -3,7 +3,6 @@ import type { MockAnyNamespace } from '@metamask/messenger'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import type { Draft, Patch } from 'immer'; -import sinon from 'sinon'; import type { ControllerActions, @@ -161,10 +160,6 @@ class MessagesController extends BaseController< } describe('BaseController', () => { - afterEach(() => { - sinon.restore(); - }); - it('should set initial state', () => { const controller = new CountController({ messenger: getCountMessenger(), @@ -383,7 +378,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); + const listener1 = jest.fn(); messenger.subscribe('CountController:stateChange', listener1); const { inversePatches } = controller.update(() => { @@ -392,13 +387,13 @@ describe('BaseController', () => { controller.applyPatches(inversePatches); - expect(listener1.callCount).toBe(2); - expect(listener1.firstCall.args).toStrictEqual([ + expect(listener1).toHaveBeenCalledTimes(2); + expect(listener1.mock.calls[0]).toStrictEqual([ { count: 1 }, [{ op: 'replace', path: [], value: { count: 1 } }], ]); - expect(listener1.secondCall.args).toStrictEqual([ + expect(listener1.mock.calls[1]).toStrictEqual([ { count: 0 }, [{ op: 'replace', path: [], value: { count: 0 } }], ]); @@ -412,8 +407,8 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); - const listener2 = sinon.stub(); + const listener1 = jest.fn(); + const listener2 = jest.fn(); messenger.subscribe('CountController:stateChange', listener1); messenger.subscribe('CountController:stateChange', listener2); @@ -421,13 +416,13 @@ describe('BaseController', () => { return { count: 1 }; }); - expect(listener1.callCount).toBe(1); - expect(listener1.firstCall.args).toStrictEqual([ + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener1.mock.calls[0]).toStrictEqual([ { count: 1 }, [{ op: 'replace', path: [], value: { count: 1 } }], ]); - expect(listener2.callCount).toBe(1); - expect(listener2.firstCall.args).toStrictEqual([ + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener2.mock.calls[0]).toStrictEqual([ { count: 1 }, [{ op: 'replace', path: [], value: { count: 1 } }], ]); @@ -441,7 +436,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener = sinon.stub(); + const listener = jest.fn(); messenger.subscribe( 'CountController:stateChange', listener, @@ -455,8 +450,8 @@ describe('BaseController', () => { return { count: 10 }; }); - expect(listener.callCount).toBe(1); - expect(listener.firstCall.args).toStrictEqual([1, 0]); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]).toStrictEqual([1, 0]); }); it('should not inform a subscriber of state changes if the selected value is unchanged', () => { @@ -467,7 +462,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener = sinon.stub(); + const listener = jest.fn(); messenger.subscribe( 'CountController:stateChange', listener, @@ -482,7 +477,7 @@ describe('BaseController', () => { return { count: 1 }; }); - expect(listener.callCount).toBe(0); + expect(listener).toHaveBeenCalledTimes(0); }); it('should inform a subscriber of each state change once even after multiple subscriptions', () => { @@ -493,7 +488,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); + const listener1 = jest.fn(); messenger.subscribe('CountController:stateChange', listener1); messenger.subscribe('CountController:stateChange', listener1); @@ -502,8 +497,8 @@ describe('BaseController', () => { return { count: 1 }; }); - expect(listener1.callCount).toBe(1); - expect(listener1.firstCall.args).toStrictEqual([ + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener1.mock.calls[0]).toStrictEqual([ { count: 1 }, [{ op: 'replace', path: [], value: { count: 1 } }], ]); @@ -517,7 +512,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); + const listener1 = jest.fn(); messenger.subscribe('CountController:stateChange', listener1); messenger.unsubscribe('CountController:stateChange', listener1); @@ -525,7 +520,7 @@ describe('BaseController', () => { return { count: 1 }; }); - expect(listener1.callCount).toBe(0); + expect(listener1).toHaveBeenCalledTimes(0); }); it('should no longer inform a subscriber about state changes after unsubscribing once, even if they subscribed many times', () => { @@ -536,7 +531,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); + const listener1 = jest.fn(); messenger.subscribe('CountController:stateChange', listener1); messenger.subscribe('CountController:stateChange', listener1); @@ -545,7 +540,7 @@ describe('BaseController', () => { return { count: 1 }; }); - expect(listener1.callCount).toBe(0); + expect(listener1).toHaveBeenCalledTimes(0); }); it('should throw when unsubscribing listener who was never subscribed', () => { @@ -558,7 +553,7 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); + const listener1 = jest.fn(); expect(() => { messenger.unsubscribe('CountController:stateChange', listener1); @@ -573,8 +568,8 @@ describe('BaseController', () => { state: { count: 0 }, metadata: countControllerStateMetadata, }); - const listener1 = sinon.stub(); - const listener2 = sinon.stub(); + const listener1 = jest.fn(); + const listener2 = jest.fn(); messenger.subscribe('CountController:stateChange', listener1); messenger.subscribe('CountController:stateChange', listener2); @@ -583,8 +578,8 @@ describe('BaseController', () => { return { count: 1 }; }); - expect(listener1.callCount).toBe(0); - expect(listener2.callCount).toBe(0); + expect(listener1).toHaveBeenCalledTimes(0); + expect(listener2).toHaveBeenCalledTimes(0); }); describe('inter-controller communication', () => { @@ -789,10 +784,6 @@ describe('BaseController', () => { }); describe('deriveStateFromMetadata', () => { - afterEach(() => { - sinon.restore(); - }); - it('returns an empty object when deriving state for an unset property', () => { const derivedState = deriveStateFromMetadata( { count: 1 }, diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index fc4af825708..66660b51ebb 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,10 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AssetPickerOpened` unified swap bridge metrics event. ([#7575](https://github.com/MetaMask/core/pull/7575)) + +### Changed + +- Updated `#getEventProperties` to fall back to stored `#location` when `location` is not provided by the client ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Replaced `@deprecated` tag on `MetaMetricsSwapsEventSource` with proper JSDoc description ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Bump `@metamask/assets-controllers` from `^99.3.2` to `^99.4.0` ([#7944](https://github.com/MetaMask/core/pull/7944)) + +### Fixed + +- Fix `usd_amount_source`, `usd_quoted_gas`, and `usd_quoted_return` metrics fields being empty for non-EVM chains by deriving USD exchange rates from multichain asset rates ([#7899](https://github.com/MetaMask/core/pull/7899)) + +## [67.0.0] + +### Added + +- **BREAKING:** Retrieve JWT token from the ProfileSyncController and include it in bridge request headers ([#7955](https://github.com/MetaMask/core/pull/7955)) + +## [66.2.0] + +### Added + +- Added `TrendingExplore` value to `MetaMetricsSwapsEventSource` enum for attributing swaps to the trending explore flow ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Added `location` as a required property on all Unified SwapBridge events in `RequiredEventContextFromClient` ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Added `setLocation()` method to `BridgeController` for clients to set the entry point when the flow starts ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Exported `MetaMetricsSwapsEventSource` from the package index ([#7931](https://github.com/MetaMask/core/pull/7931)) + +## [66.1.1] + +### Fixed + +- Return 0-prefixed hex string from `formatChainIdToHex` utility ([#7909](https://github.com/MetaMask/core/pull/7909)) + +## [66.1.0] [DEPRECATED] + +### Added + +- Add support for Tron assets in the `formatAddressToAssetId` utility ([#7896](https://github.com/MetaMask/core/pull/7896)) + ### Changed +- Refresh asset exchange rates each time quotes are fetched ([#7896](https://github.com/MetaMask/core/pull/7896)) +- Return checksummed EVM assetIds from the `formatAddressToAssetId` utility ([#7896](https://github.com/MetaMask/core/pull/7896)) - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) -- Bump `@metamask/transaction-controller` from `^62.15.0` to `^62.16.0` ([#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/transaction-controller` from `^62.15.0` to `^62.17.0` ([#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/multichain-network-controller` from `^3.0.2` to `^3.0.3` ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/assets-controllers` from `^99.3.1` to `^99.3.2` ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) + +### Fixed + +- Fall back to the quoted `priceImpact` or `destTokenAmount` to sort quotes if the `cost` is not available ([#7896](https://github.com/MetaMask/core/pull/7896)) ## [66.0.0] @@ -1109,7 +1159,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@66.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@67.0.0...HEAD +[67.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@66.2.0...@metamask/bridge-controller@67.0.0 +[66.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@66.1.1...@metamask/bridge-controller@66.2.0 +[66.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@66.1.0...@metamask/bridge-controller@66.1.1 +[66.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@66.0.0...@metamask/bridge-controller@66.1.0 [66.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@65.3.0...@metamask/bridge-controller@66.0.0 [65.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@65.2.0...@metamask/bridge-controller@65.3.0 [65.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@65.1.0...@metamask/bridge-controller@65.2.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 97eb6009558..3b0740f90f7 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "66.0.0", + "version": "67.0.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -53,20 +53,21 @@ "@ethersproject/constants": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/accounts-controller": "^35.0.2", - "@metamask/assets-controllers": "^99.3.1", + "@metamask/accounts-controller": "^36.0.0", + "@metamask/assets-controllers": "^99.4.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/gas-fee-controller": "^26.0.2", "@metamask/keyring-api": "^21.5.0", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", - "@metamask/multichain-network-controller": "^3.0.2", + "@metamask/multichain-network-controller": "^3.0.3", "@metamask/network-controller": "^29.0.0", "@metamask/polling-controller": "^16.0.2", + "@metamask/profile-sync-controller": "^27.1.0", "@metamask/remote-feature-flag-controller": "^4.0.0", "@metamask/snaps-controllers": "^17.2.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "reselect": "^5.1.1", @@ -77,14 +78,14 @@ "@metamask/eth-json-rpc-provider": "^6.0.0", "@metamask/superstruct": "^3.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "lodash": "^4.17.21", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index aaf5643b16e..7846f229bc9 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -1,14 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BridgeController SSE should publish validation failures 4`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Quotes Failed Validation", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", - "failures": Array [ + "failures": [ "lifi|trade", "lifi|trade.chainId", "lifi|trade.to", @@ -20,34 +20,37 @@ Array [ "lifi|trade.inputsToSign", "lifi|trade.raw_data_hex", ], + "location": "Main View", "refresh_count": 1, "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", "token_address_source": "eip155:1/slip44:60", }, ], - Array [ + [ "Unified SwapBridge Quotes Failed Validation", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", - "failures": Array [ + "failures": [ "unknown|unknown", ], + "location": "Main View", "refresh_count": 1, "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", "token_address_source": "eip155:1/slip44:60", }, ], - Array [ + [ "Unified SwapBridge Quotes Failed Validation", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", - "failures": Array [ + "failures": [ "unknown|quote", ], + "location": "Main View", "refresh_count": 1, "token_address_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", "token_address_source": "eip155:1/slip44:60", @@ -57,16 +60,17 @@ Array [ `; exports[`BridgeController SSE should replace all stale quotes after a refresh and first quote is received 1`] = ` -Array [ +[ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -75,23 +79,24 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ] `; exports[`BridgeController SSE should reset and refetch quotes after quote request is changed 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -106,17 +111,18 @@ Array [ `; exports[`BridgeController SSE should reset quotes list if quote refresh fails 2`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -125,12 +131,12 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Error", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", @@ -138,7 +144,8 @@ Array [ "error_message": "Network error", "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -147,23 +154,23 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], ] `; exports[`BridgeController SSE should rethrow error from server 1`] = ` -Object { - "assetExchangeRates": Object { - "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { "exchangeRate": undefined, "usdExchangeRate": "100", }, }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", @@ -175,7 +182,7 @@ Object { "srcTokenAmount": "1000000000000000000", "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", }, - "quotes": Array [], + "quotes": [], "quotesInitialLoadTime": null, "quotesLoadingStatus": 0, "quotesRefreshCount": 0, @@ -183,49 +190,54 @@ Object { `; exports[`BridgeController SSE should rethrow error from server 3`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_source", "input_value": "eip155:1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_destination", "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "token_destination", "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "slippage", "input_value": 0.5, + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -234,12 +246,12 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Error", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", @@ -247,7 +259,8 @@ Array [ "error_message": "Bridge-api error: timeout from server", "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -256,23 +269,23 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], ] `; exports[`BridgeController SSE should trigger quote polling if request is valid 1`] = ` -Object { - "assetExchangeRates": Object { - "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { "exchangeRate": undefined, "usdExchangeRate": "100", }, }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", @@ -284,7 +297,7 @@ Object { "srcTokenAmount": "1000000000000000000", "walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294", }, - "quotes": Array [], + "quotes": [], "quotesInitialLoadTime": null, "quotesLoadingStatus": 0, "quotesRefreshCount": 0, @@ -292,49 +305,54 @@ Object { `; exports[`BridgeController SSE should trigger quote polling if request is valid 2`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_source", "input_value": "eip155:1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_destination", "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "token_destination", "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "slippage", "input_value": 0.5, + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -343,7 +361,7 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], ] diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index f8441e5766b..0fc2daeefc9 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -1,16 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = ` -Object { - "assetExchangeRates": Object { - "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { "exchangeRate": undefined, "usdExchangeRate": "100", }, }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "destChainId": "0x1", "destTokenAddress": "0x0000000000000000000000000000000000000000", "insufficientBal": false, @@ -27,16 +27,16 @@ Object { `; exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = ` -Object { - "assetExchangeRates": Object { - "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { "exchangeRate": undefined, "usdExchangeRate": "100", }, }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "destChainId": "0x1", "destTokenAddress": "0x0000000000000000000000000000000000000000", "insufficientBal": false, @@ -53,10 +53,10 @@ Object { `; exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Completed event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Completed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 10, "approval_transaction": "PENDING", @@ -90,10 +90,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Failed event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 10, "allowance_reset_transaction": "PENDING", @@ -108,12 +108,13 @@ Array [ "has_gas_included_quote": false, "initial_load_time_all_quotes": 0, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "provider_bridge", "quoted_time_minutes": 0, "quotes_count": 0, - "quotes_list": Array [], - "security_warnings": Array [], + "quotes_list": [], + "security_warnings": [], "slippage_limit": undefined, "source_transaction": "PENDING", "stx_enabled": false, @@ -131,10 +132,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Failed event before tx is submitted 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -145,11 +146,12 @@ Array [ "has_gas_included_quote": false, "initial_load_time_all_quotes": 0, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 12, "provider": "provider_bridge", "quoted_time_minutes": 2, "quotes_count": 2, - "quotes_list": Array [ + "quotes_list": [ "lifi_mayan", "lifi_mayanMCTP", ], @@ -169,24 +171,26 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the StatusValidationFailed event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Status Failed Validation", - Object { + { "action_type": "swapbridge-v1", - "failures": Array [ + "failures": [ "Failed to submit tx", ], + "location": "Main View", + "refresh_count": 0, }, ], ] `; exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller calls should track the Submitted event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -211,10 +215,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the AllQuotesOpened event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge All Quotes Opened", - Object { + { "action_type": "swapbridge-v1", "can_submit": true, "chain_id_destination": null, @@ -224,9 +228,10 @@ Array [ "has_gas_included_quote": false, "initial_load_time_all_quotes": 0, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 6, "quotes_count": 0, - "quotes_list": Array [], + "quotes_list": [], "slippage_limit": undefined, "stx_enabled": false, "swap_type": "crosschain", @@ -240,10 +245,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the AllQuotesSorted event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge All Quotes Sorted", - Object { + { "action_type": "swapbridge-v1", "best_quote_provider": "provider_bridge2", "can_submit": true, @@ -254,9 +259,10 @@ Array [ "has_gas_included_quote": false, "initial_load_time_all_quotes": 0, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 6, "quotes_count": 0, - "quotes_list": Array [], + "quotes_list": [], "slippage_limit": undefined, "sort_order": "cost_ascending", "stx_enabled": false, @@ -271,13 +277,14 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the AssetDetailTooltipClicked event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Asset Detail Tooltip Clicked", - Object { + { "action_type": "swapbridge-v1", "chain_id": "1", "chain_name": "Ethereum", + "location": "Main View", "token_contract": "0x123", "token_name": "ETH", "token_symbol": "ETH", @@ -287,10 +294,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the ButtonClicked event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Button Clicked", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": null, "chain_id_source": "eip155:1", @@ -305,14 +312,15 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the InputSourceDestinationFlipped event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Source Destination Switched", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:1", - "security_warnings": Array [ + "location": "Main View", + "security_warnings": [ "warning1", ], "token_address_destination": "eip155:10/slip44:60", @@ -325,14 +333,15 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the PageViewed event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Page Viewed", - Object { + { "abc": 1, "action_type": "swapbridge-v1", "chain_id_destination": null, "chain_id_source": "eip155:1", + "location": "Main View", "token_address_destination": null, "token_address_source": "eip155:1/slip44:60", }, @@ -341,10 +350,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the QuoteSelected event 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Quote Selected", - Object { + { "action_type": "swapbridge-v1", "best_quote_provider": "provider_bridge2", "can_submit": false, @@ -357,11 +366,12 @@ Array [ "initial_load_time_all_quotes": 0, "is_best_quote": true, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "provider_bridge", "quoted_time_minutes": 10, "quotes_count": 0, - "quotes_list": Array [], + "quotes_list": [], "slippage_limit": undefined, "swap_type": "crosschain", "token_address_destination": null, @@ -374,8 +384,8 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the QuotesReceived event 1`] = ` -Array [ - Array [ +[ + [ "AccountsController:getAccountByAddress", "0x123", ], @@ -383,10 +393,10 @@ Array [ `; exports[`BridgeController trackUnifiedSwapBridgeEvent client-side calls should track the QuotesReceived event 2`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Quotes Received", - Object { + { "action_type": "swapbridge-v1", "best_quote_provider": "provider_bridge2", "can_submit": true, @@ -398,11 +408,12 @@ Array [ "has_gas_included_quote": false, "initial_load_time_all_quotes": 0, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "provider_bridge", "quoted_time_minutes": 10, "quotes_count": 0, - "quotes_list": Array [], + "quotes_list": [], "refresh_count": 0, "slippage_limit": undefined, "swap_type": "crosschain", @@ -411,7 +422,7 @@ Array [ "usd_balance_source": 0, "usd_quoted_gas": 0, "usd_quoted_return": 100, - "warnings": Array [ + "warnings": [ "insufficient_balance", ], }, @@ -420,49 +431,54 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams should only poll once if insufficientBal=true 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_source", "input_value": "eip155:1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_destination", "input_value": "eip155:10", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "token_destination", "input_value": "eip155:10/erc20:0x123", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "slippage", "input_value": 0.5, + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -471,12 +487,12 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Received", - Object { + { "action_type": "swapbridge-v1", "best_quote_provider": "provider_bridge2", "can_submit": true, @@ -488,11 +504,12 @@ Array [ "has_gas_included_quote": false, "initial_load_time_all_quotes": 11000, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "provider_bridge", "quoted_time_minutes": 10, "quotes_count": 2, - "quotes_list": Array [ + "quotes_list": [ "lifi_across", "lifi_celercircle", ], @@ -504,7 +521,7 @@ Array [ "usd_balance_source": 0, "usd_quoted_gas": 0, "usd_quoted_return": 100, - "warnings": Array [ + "warnings": [ "low_return", ], }, @@ -513,8 +530,8 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams should reset minimumBalanceForRentExemptionInLamports if getMinimumBalanceForRentExemption call fails 1`] = ` -Array [ - Array [ +[ + [ "Error setting minimum balance for rent exemption", [Error: Min balance error], ], @@ -522,23 +539,23 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams should reset minimumBalanceForRentExemptionInLamports if getMinimumBalanceForRentExemption call fails 2`] = ` -Array [ - Array [ +[ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -549,16 +566,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -567,16 +584,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", @@ -585,22 +602,22 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -611,22 +628,22 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -637,16 +654,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -655,16 +672,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", @@ -673,16 +690,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -691,16 +708,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", @@ -709,22 +726,22 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -735,16 +752,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -753,16 +770,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", @@ -775,16 +792,11 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 1`] = ` -Object { - "assetExchangeRates": Object { - "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { - "exchangeRate": undefined, - "usdExchangeRate": "100", - }, - }, +{ + "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", @@ -795,7 +807,7 @@ Object { "srcTokenAmount": "10", "walletAddress": "0x123", }, - "quotes": Array [], + "quotes": [], "quotesInitialLoadTime": null, "quotesLastFetched": null, "quotesLoadingStatus": 0, @@ -804,16 +816,16 @@ Object { `; exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 2`] = ` -Object { - "assetExchangeRates": Object { - "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": Object { +{ + "assetExchangeRates": { + "eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984": { "exchangeRate": undefined, "usdExchangeRate": "100", }, }, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "destChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "destTokenAddress": "123d1", "destWalletAddress": "SolanaWalletAddres1234", @@ -832,49 +844,54 @@ Object { `; exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote polling if request is valid 3`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_source", "input_value": "eip155:1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_destination", "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "token_destination", "input_value": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:123d1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "slippage", "input_value": 0.5, + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -883,19 +900,20 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -904,19 +922,20 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -925,12 +944,12 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Error", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", @@ -938,7 +957,8 @@ Array [ "error_message": "Network error", "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -947,19 +967,20 @@ Array [ "token_symbol_destination": "USDC", "token_symbol_source": "ETH", "usd_amount_source": 100, - "warnings": Array [], + "warnings": [], }, ], - Array [ + [ "Unified SwapBridge Quotes Requested", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, "has_sufficient_funds": true, "is_hardware_wallet": false, - "security_warnings": Array [], + "location": "Main View", + "security_warnings": [], "slippage_limit": 0.5, "stx_enabled": true, "swap_type": "crosschain", @@ -974,52 +995,55 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams should update the quoteRequest state 1`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_source", "input_value": "eip155:1", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "chain_destination", "input_value": "eip155:10", + "location": "Main View", }, ], - Array [ + [ "Unified SwapBridge Input Changed", - Object { + { "action_type": "swapbridge-v1", "input": "slippage", "input_value": 0.5, + "location": "Main View", }, ], ] `; exports[`BridgeController updateBridgeQuoteRequestParams: should append solanaFees for Solana quotes 1`] = ` -Array [ - Array [ +[ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -1030,16 +1054,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -1048,16 +1072,16 @@ Array [ "snapId": "npm:@metamask/solana-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "computeFee", - "params": Object { + "params": { "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", @@ -1069,26 +1093,26 @@ Array [ ] `; -exports[`BridgeController updateBridgeQuoteRequestParams: should append solanaFees for Solana quotes 2`] = `Array []`; +exports[`BridgeController updateBridgeQuoteRequestParams: should append solanaFees for Solana quotes 2`] = `[]`; exports[`BridgeController updateBridgeQuoteRequestParams: should handle malformed quotes 1`] = ` -Array [ - Array [ +[ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -1103,18 +1127,19 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams: should handle malformed quotes 2`] = ` -Array [ - Array [ +[ + [ "Unified SwapBridge Quotes Failed Validation", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "failures": Array [ + "failures": [ "socket|quote.srcAsset.decimals", "socket|quote.destAsset.address", "lifi|quote.srcAsset.decimals", ], + "location": "Main View", "refresh_count": 0, "token_address_destination": "eip155:1/slip44:60", "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:NATIVE", @@ -1124,23 +1149,23 @@ Array [ `; exports[`BridgeController updateBridgeQuoteRequestParams: should handle mixed Solana and non-Solana quotes by not appending fees 1`] = ` -Array [ - Array [ +[ + [ "SnapController:handleRequest", - Object { + { "handler": "onProtocolRequest", "origin": "metamask", - "request": Object { + "request": { "jsonrpc": "2.0", "method": " ", - "params": Object { - "request": Object { + "params": { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "getMinimumBalanceForRentExemption", - "params": Array [ + "params": [ 0, - Object { + { "commitment": "confirmed", }, ], @@ -1154,8 +1179,8 @@ Array [ ] `; -exports[`BridgeController updateBridgeQuoteRequestParams: should handle mixed Solana and non-Solana quotes by not appending fees 2`] = `Array []`; +exports[`BridgeController updateBridgeQuoteRequestParams: should handle mixed Solana and non-Solana quotes by not appending fees 2`] = `[]`; -exports[`BridgeController updateBridgeQuoteRequestParams: should not append solanaFees if selected account is not a snap 1`] = `Array []`; +exports[`BridgeController updateBridgeQuoteRequestParams: should not append solanaFees if selected account is not a snap 1`] = `[]`; -exports[`BridgeController updateBridgeQuoteRequestParams: should not append solanaFees if selected account is not a snap 2`] = `Array []`; +exports[`BridgeController updateBridgeQuoteRequestParams: should not append solanaFees if selected account is not a snap 2`] = `[]`; diff --git a/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap b/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap new file mode 100644 index 00000000000..bf75c6085e9 --- /dev/null +++ b/packages/bridge-controller/src/__snapshots__/selectors.test.ts.snap @@ -0,0 +1,1210 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bridge Selectors selectBridgeQuotes should return sorted quotes with metadata 1`] = ` +{ + "activeQuote": { + "adjustedReturn": { + "usd": "13.099927", + "valueInCurrency": "2597.985546", + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": "-2.099927", + "valueInCurrency": "-419.985546", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.000067", + "valueInCurrency": "0.013266", + }, + "max": { + "amount": "0.0000146", + "usd": "0.000146", + "valueInCurrency": "0.028908", + }, + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2100000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "456", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-10.999854", + "valueInCurrency": "-2177.971092", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-10.999927", + "valueInCurrency": "-2177.985546", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + "isLoading": false, + "isQuoteGoingToRefresh": true, + "quoteFetchError": null, + "quotesRefreshCount": 0, + "recommendedQuote": { + "adjustedReturn": { + "usd": "13.099927", + "valueInCurrency": "2597.985546", + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": "-2.099927", + "valueInCurrency": "-419.985546", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.000067", + "valueInCurrency": "0.013266", + }, + "max": { + "amount": "0.0000146", + "usd": "0.000146", + "valueInCurrency": "0.028908", + }, + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2100000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "456", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-10.999854", + "valueInCurrency": "-2177.971092", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-10.999927", + "valueInCurrency": "-2177.985546", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + "sortedQuotes": [ + { + "adjustedReturn": { + "usd": "13.099927", + "valueInCurrency": "2597.985546", + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": "-2.099927", + "valueInCurrency": "-419.985546", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.000067", + "valueInCurrency": "0.013266", + }, + "max": { + "amount": "0.0000146", + "usd": "0.000146", + "valueInCurrency": "0.028908", + }, + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2100000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "456", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": "2.1", + "valueInCurrency": "420", + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-10.999854", + "valueInCurrency": "-2177.971092", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-10.999927", + "valueInCurrency": "-2177.985546", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + { + "adjustedReturn": { + "usd": "12.999927", + "valueInCurrency": "2577.985546", + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": "-1.999927", + "valueInCurrency": "-399.985546", + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.000067", + "valueInCurrency": "0.013266", + }, + "max": { + "amount": "0.0000146", + "usd": "0.000146", + "valueInCurrency": "0.028908", + }, + "total": { + "amount": "0.0000073", + "usd": "0.000073", + "valueInCurrency": "0.014454", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": "1.8", + "valueInCurrency": "360", + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2000000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "123", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "11", + "valueInCurrency": "2178", + }, + "swapRate": "1.81818181818181818182", + "toTokenAmount": { + "amount": "2", + "usd": "2", + "valueInCurrency": "400", + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-10.999854", + "valueInCurrency": "-2177.971092", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-10.999927", + "valueInCurrency": "-2177.985546", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + ], +} +`; + +exports[`Bridge Selectors selectBridgeQuotes should use destTokenAmount to sort quotes if exchange rate is not available 1`] = ` +{ + "activeQuote": { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2100000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "456", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + "isLoading": false, + "isQuoteGoingToRefresh": true, + "quoteFetchError": null, + "quotesRefreshCount": 0, + "recommendedQuote": { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2100000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "456", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + "sortedQuotes": [ + { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2100000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "456", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.90909090909090909091", + "toTokenAmount": { + "amount": "2.1", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2000000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "requestId": "123", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.81818181818181818182", + "toTokenAmount": { + "amount": "2", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + ], +} +`; + +exports[`Bridge Selectors selectBridgeQuotes should use priceImpact to sort quotes if exchange rate is not available 1`] = ` +{ + "activeQuote": { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2000000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "priceData": { + "priceImpact": "-0.02", + }, + "requestId": "123", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.81818181818181818182", + "toTokenAmount": { + "amount": "2", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + "isLoading": false, + "isQuoteGoingToRefresh": true, + "quoteFetchError": null, + "quotesRefreshCount": 0, + "recommendedQuote": { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2000000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "priceData": { + "priceImpact": "-0.02", + }, + "requestId": "123", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.81818181818181818182", + "toTokenAmount": { + "amount": "2", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + "sortedQuotes": [ + { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2000000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "priceData": { + "priceImpact": "-0.02", + }, + "requestId": "123", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.81818181818181818182", + "toTokenAmount": { + "amount": "2", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + { + "adjustedReturn": { + "usd": null, + "valueInCurrency": null, + }, + "approval": { + "effectiveGas": "46000", + "gasLimit": "49000", + }, + "cost": { + "usd": null, + "valueInCurrency": null, + }, + "estimatedProcessingTimeInSeconds": 300, + "gasFee": { + "effective": { + "amount": "0.0000067", + "usd": "0.01206", + "valueInCurrency": "0.01206", + }, + "max": { + "amount": "0.0000146", + "usd": "0.02628", + "valueInCurrency": "0.02628", + }, + "total": { + "amount": "0.0000073", + "usd": "0.01314", + "valueInCurrency": "0.01314", + }, + }, + "includedTxFees": null, + "minToTokenAmount": { + "amount": "1.8", + "usd": null, + "valueInCurrency": null, + }, + "quote": { + "bridgeId": "bridge1", + "bridges": [ + "bridge1", + ], + "destAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "destChainId": "137", + "destTokenAmount": "2000000000000000000", + "feeData": { + "metabridge": { + "amount": "100000000000000000", + "asset": { + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + }, + }, + }, + "minDestTokenAmount": "1800000000000000000", + "priceData": { + "priceImpact": "0.01", + }, + "requestId": "123", + "srcAsset": { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:1/erc20:0x0000000000000000000000000000000000000000", + "decimals": 18, + }, + "srcChainId": "1", + "srcTokenAmount": "1000000000000000000", + "steps": [ + "step1", + ], + }, + "sentAmount": { + "amount": "1.1", + "usd": "1980", + "valueInCurrency": "1980", + }, + "swapRate": "1.81818181818181818182", + "toTokenAmount": { + "amount": "2", + "usd": null, + "valueInCurrency": null, + }, + "totalMaxNetworkFee": { + "amount": "-1.0999854", + "usd": "-1979.97372", + "valueInCurrency": "-1979.97372", + }, + "totalNetworkFee": { + "amount": "-1.0999927", + "usd": "-1979.98686", + "valueInCurrency": "-1979.98686", + }, + "trade": { + "effectiveGas": "21000", + "gasLimit": "24000", + "value": "0x0", + }, + }, + ], +} +`; diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 6414105ff7b..6eaeee73700 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -92,13 +92,22 @@ describe('BridgeController SSE', function () { }, }); getLayer1GasFeeMock.mockResolvedValue('0x1'); - messengerMock.call.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never); + messengerMock.call.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + }; + } + }, + ); jest.spyOn(featureFlagUtils, 'getBridgeFeatureFlags').mockReturnValue({ minimumVersion: '0.0.0', maxRefreshCount: 5, @@ -161,17 +170,17 @@ describe('BridgeController SSE', function () { }, context: metricsContext, }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quoteRequest, - assetExchangeRates, quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); // Loading state jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, { @@ -181,6 +190,7 @@ describe('BridgeController SSE', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { onValidationFailure: expect.any(Function), @@ -198,6 +208,7 @@ describe('BridgeController SSE', function () { // After first fetch jest.advanceTimersByTime(5000); await flushPromises(); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); expect(bridgeController.state).toStrictEqual({ ...expectedState, quotesInitialLoadTime: 6000, @@ -214,6 +225,7 @@ describe('BridgeController SSE', function () { quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, + assetExchangeRates, }); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledTimes(0); @@ -301,13 +313,13 @@ describe('BridgeController SSE', function () { const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quoteRequest: usdtQuoteRequest, - assetExchangeRates, quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); // Loading state jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, { @@ -317,6 +329,7 @@ describe('BridgeController SSE', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { onValidationFailure: expect.any(Function), @@ -357,6 +370,7 @@ describe('BridgeController SSE', function () { quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, + assetExchangeRates, }); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledTimes(0); @@ -366,13 +380,20 @@ describe('BridgeController SSE', function () { ); it('should use resetApproval and insufficientBal fallback values if provider is not found', async function () { - messengerMock.call.mockReturnValue({ - address: '0x123', - provider: undefined, - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never); + messengerMock.call.mockImplementation( + (...args: Parameters) => { + if (args[0] === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + return { + address: '0x123', + provider: undefined, + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + }, + ); const mockUSDTQuoteResponse = mockBridgeQuotesErc20Erc20.map((quote) => ({ ...quote, quote: { @@ -424,13 +445,14 @@ describe('BridgeController SSE', function () { const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quoteRequest: usdtQuoteRequest, - assetExchangeRates, quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); // Loading state jest.advanceTimersByTime(1000); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, { @@ -440,6 +462,7 @@ describe('BridgeController SSE', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { onValidationFailure: expect.any(Function), @@ -478,6 +501,7 @@ describe('BridgeController SSE', function () { quotesRefreshCount: 1, quotesLoadingStatus: 1, quotesLastFetched: t1, + assetExchangeRates, }); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledTimes(0); @@ -502,6 +526,8 @@ describe('BridgeController SSE', function () { quoteRequest, metricsContext, ); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); // 1st fetch jest.advanceTimersByTime(FIRST_FETCH_DELAY); await flushPromises(); @@ -591,6 +617,8 @@ describe('BridgeController SSE', function () { ); consoleLogSpy.mockImplementationOnce(jest.fn()); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); // 1st fetch jest.advanceTimersByTime(FIRST_FETCH_DELAY); await flushPromises(); @@ -638,8 +666,8 @@ describe('BridgeController SSE', function () { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ).toBeGreaterThan(t2!); expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ "Failed to stream bridge quotes", "Network error", ], @@ -683,6 +711,10 @@ describe('BridgeController SSE', function () { consoleLogSpy.mockImplementationOnce(jest.fn()); hasSufficientBalanceSpy.mockRejectedValue(new Error('Balance error')); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st fetch jest.advanceTimersByTime(FIRST_FETCH_DELAY); await flushPromises(); @@ -715,7 +747,7 @@ describe('BridgeController SSE', function () { ...quoteRequest, srcTokenAmount: '10', }, - assetExchangeRates, + assetExchangeRates: {}, }; // Start new quote request await bridgeController.updateBridgeQuoteRequestParams( @@ -743,6 +775,8 @@ describe('BridgeController SSE', function () { quotesLoadingStatus: RequestStatus.LOADING, }); const t1 = bridgeController.state.quotesLastFetched; + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); // 1st quote is received await advanceToNthTimerThenFlush(); const expectedStateAfterFirstQuote = { @@ -764,6 +798,7 @@ describe('BridgeController SSE', function () { resetApproval: false, }, quotesLastFetched: t1, + assetExchangeRates, }; expect(bridgeController.state.quotes).toHaveLength(1); expect(bridgeController.state).toStrictEqual({ @@ -788,6 +823,7 @@ describe('BridgeController SSE', function () { l1GasFeesInHexWei: '0x1', resetApproval: undefined, })), + assetExchangeRates, }); expect( bridgeController.state.quotesLastFetched, @@ -851,6 +887,10 @@ describe('BridgeController SSE', function () { .spyOn(console, 'warn') .mockImplementationOnce(jest.fn()) .mockImplementationOnce(jest.fn()); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st fetch jest.advanceTimersByTime(FIRST_FETCH_DELAY); await flushPromises(); @@ -858,11 +898,13 @@ describe('BridgeController SSE', function () { expect(startPollingSpy).toHaveBeenCalledTimes(1); // Wait for next polling interval - jest.advanceTimersToNextTimer(); - await flushPromises(); + await advanceToNthTimerThenFlush(); + + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); // 2nd fetch - await advanceToNthTimerThenFlush(2); + await advanceToNthTimerThenFlush(1); expect(bridgeController.state.quotesRefreshCount).toBe(2); // 3nd fetch throws an error @@ -884,9 +926,13 @@ describe('BridgeController SSE', function () { }, ); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); + // 1st quote is received jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); await flushPromises(); + const t4 = bridgeController.state.quotesLastFetched; expect(t4).toBe( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -896,7 +942,7 @@ describe('BridgeController SSE', function () { expect(bridgeController.state.quotesLoadingStatus).toBe( RequestStatus.LOADING, ); - // expect(bridgeController.state.quotes).toStrictEqual([]); + // 2nd quote is received await advanceToNthTimerThenFlush(3); expect(bridgeController.state.quotes).toStrictEqual( @@ -941,11 +987,10 @@ describe('BridgeController SSE', function () { const t6 = bridgeController.state.quotesLastFetched; expect(t6).toBeCloseTo(Date.now() - 2000); // Empty event.data - jest.advanceTimersByTime(FOURTH_FETCH_DELAY - 1000); - await flushPromises(); + await advanceToNthTimerThenFlush(); // Valid quote - jest.advanceTimersByTime(FOURTH_FETCH_DELAY * 2 - 1000); - await flushPromises(); + await advanceToNthTimerThenFlush(); + await advanceToNthTimerThenFlush(); expect(bridgeController.state).toStrictEqual(expectedState); const t7 = bridgeController.state.quotesLastFetched; expect(t7).toBe( @@ -953,9 +998,9 @@ describe('BridgeController SSE', function () { t6!, ); expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [ "Quote validation failed", - Array [ + [ "lifi|trade", "lifi|trade.chainId", "lifi|trade.to", @@ -983,21 +1028,21 @@ describe('BridgeController SSE', function () { ); expect(consoleWarnSpy.mock.calls).toHaveLength(3); expect(consoleWarnSpy.mock.calls[1]).toMatchInlineSnapshot(` - Array [ + [ "Quote validation failed", - Array [ + [ "unknown|unknown", ], ] `); expect(consoleWarnSpy.mock.calls[2]).toMatchInlineSnapshot(` - Array [ - "Quote validation failed", - Array [ - "unknown|quote", - ], - ] - `); + [ + "Quote validation failed", + [ + "unknown|quote", + ], + ] + `); expect(consoleLogSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(5); @@ -1029,17 +1074,18 @@ describe('BridgeController SSE', function () { }, context: metricsContext, }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); const expectedState = { ...DEFAULT_BRIDGE_CONTROLLER_STATE, quoteRequest, - assetExchangeRates, + assetExchangeRates: {}, quotesLoadingStatus: RequestStatus.LOADING, }; expect(bridgeController.state).toStrictEqual(expectedState); // Loading state jest.advanceTimersByTime(1000); + // Wait for JWT token retrieval + await advanceToNthTimerThenFlush(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( mockFetchFn, { @@ -1049,6 +1095,7 @@ describe('BridgeController SSE', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { onValidationFailure: expect.any(Function), @@ -1057,6 +1104,7 @@ describe('BridgeController SSE', function () { }, '13.8.0', ); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); const { quotesLastFetched: t1, ...stateWithoutTimestamp } = bridgeController.state; // eslint-disable-next-line jest/no-restricted-matchers @@ -1068,6 +1116,7 @@ describe('BridgeController SSE', function () { await flushPromises(); expect(bridgeController.state).toStrictEqual({ ...expectedState, + assetExchangeRates, quoteRequest: { ...quoteRequest, insufficientBal: false, @@ -1081,7 +1130,7 @@ describe('BridgeController SSE', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy).toHaveBeenCalledTimes(1); expect(consoleLogSpy.mock.calls[0]).toMatchInlineSnapshot(` - Array [ + [ "Failed to stream bridge quotes", [Error: Bridge-api error: timeout from server], ] diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 481f2aa8e3c..eb8709d6a42 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -41,11 +41,12 @@ 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'; import mockBridgeQuotesSolErc20 from '../tests/mock-quotes-sol-erc20.json'; +import { advanceToNthTimerThenFlush } from '../tests/mock-sse'; const EMPTY_INIT_STATE = DEFAULT_BRIDGE_CONTROLLER_STATE; jest.mock('uuid', () => ({ - v4: () => 'test-uuid-1234', + v4: (): string => 'test-uuid-1234', })); const messengerMock = { @@ -366,7 +367,7 @@ describe('BridgeController', function () { }, context: metricsContext, }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ @@ -381,6 +382,7 @@ describe('BridgeController', function () { await flushPromises(); expect(fetchBridgeQuotesSpy).not.toHaveBeenCalled(); expect(fetchQuotesStreamSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); }); it('updateBridgeQuoteRequestParams should trigger quote polling if request is valid', async function () { @@ -390,19 +392,28 @@ describe('BridgeController', function () { const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(true); - messengerMock.call.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - currencyRates: {}, - marketData: {}, - conversionRates: {}, - remoteFeatureFlags: { - bridgeConfig: { - ...bridgeConfig, - sse: { enabled: true, minimumVersion: '13.9.0' }, - }, + messengerMock.call.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currencyRates: {}, + marketData: {}, + conversionRates: {}, + remoteFeatureFlags: { + bridgeConfig: { + ...bridgeConfig, + sse: { enabled: true, minimumVersion: '13.9.0' }, + }, + }, + } as never; + } }, - } as never); + ); const fetchBridgeQuotesSpy = jest .spyOn(fetchUtils, 'fetchBridgeQuotes') @@ -486,7 +497,7 @@ describe('BridgeController', function () { }, context: metricsContext, }); - expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(0); expect(bridgeController.state).toStrictEqual( expect.objectContaining({ @@ -501,6 +512,7 @@ describe('BridgeController', function () { jest.advanceTimersByTime(1000); await flushPromises(); expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); + expect(fetchAssetPricesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy).toHaveBeenCalledWith( { ...quoteRequest, @@ -509,6 +521,7 @@ describe('BridgeController', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, null, @@ -660,7 +673,7 @@ describe('BridgeController', function () { .spyOn(console, 'warn') .mockImplementation(jest.fn()); - const setupMessengerMock = (shouldMinBalanceFail = false) => { + const setupMessengerMock = (shouldMinBalanceFail = false): void => { messengerMock.call.mockImplementation( ( ...args: Parameters @@ -958,7 +971,7 @@ describe('BridgeController', function () { action.includes('SnapController'), ), ).toMatchSnapshot(); - expect(consoleWarnSpy).toHaveBeenCalledTimes(5); + expect(consoleWarnSpy).toHaveBeenCalledTimes(4); expect(consoleWarnSpy).toHaveBeenCalledWith( 'Failed to fetch asset exchange rates', new Error('Currency rate error'), @@ -972,14 +985,23 @@ describe('BridgeController', function () { const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(false); - messengerMock.call.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - currentCurrency: 'usd', - currencyRates: {}, - marketData: {}, - conversionRates: {}, - } as never); + messengerMock.call.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); jest .spyOn(selectors, 'selectIsAssetExchangeRateInState') .mockReturnValue(true); @@ -1063,6 +1085,7 @@ describe('BridgeController', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, null, @@ -1358,6 +1381,124 @@ describe('BridgeController', function () { ); }); + it('updateBridgeQuoteRequestParams should include undefined Authentication header if getBearerToken throws an error', async function () { + jest.useFakeTimers(); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerMock.call.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + default: + return { + address: '0x123', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); + }); + }); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + + await bridgeController.updateBridgeQuoteRequestParams( + quoteParams, + metricsContext, + ); + + await advanceToNthTimerThenFlush(); + + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBeUndefined(); + }); + + it('updateBridgeQuoteRequestParams should include auth token as Authentication header', async function () { + jest.useFakeTimers(); + const startPollingSpy = jest.spyOn(bridgeController, 'startPolling'); + messengerMock.call.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + currentCurrency: 'usd', + currencyRates: {}, + marketData: {}, + conversionRates: {}, + } as never; + } + }, + ); + jest + .spyOn(selectors, 'selectIsAssetExchangeRateInState') + .mockReturnValue(true); + + const fetchBridgeQuotesSpy = jest + .spyOn(fetchUtils, 'fetchBridgeQuotes') + .mockImplementationOnce(async () => { + return await new Promise((resolve) => { + return setTimeout(() => { + resolve({ + quotes: mockBridgeQuotesNativeErc20Eth as never, + validationFailures: [], + }); + }, 5000); + }); + }); + + const quoteParams = { + srcChainId: '0x1', + destChainId: '0xa', + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: '0x123', + srcTokenAmount: '1000000000000000000', + walletAddress: '0x123', + slippage: 0.5, + }; + + await bridgeController.updateBridgeQuoteRequestParams( + quoteParams, + metricsContext, + ); + + await advanceToNthTimerThenFlush(); + + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeQuotesSpy.mock.calls[0][3]).toBe('AUTH_TOKEN'); + }); + it.each([ [ 'should append l1GasFees if srcChain is 10 and srcToken is erc20', @@ -1409,10 +1550,19 @@ describe('BridgeController', function () { const hasSufficientBalanceSpy = jest .spyOn(balanceUtils, 'hasSufficientBalance') .mockResolvedValue(false); - messengerMock.call.mockReturnValue({ - address: '0x123', - provider: jest.fn(), - } as never); + messengerMock.call.mockImplementation( + (...args: Parameters) => { + switch (args[0]) { + case 'AuthenticationController:getBearerToken': + return 'AUTH_TOKEN'; + default: + return { + address: '0x123', + provider: jest.fn(), + } as never; + } + }, + ); for (const [index, quote] of quoteResponse.entries()) { if (tradeL1GasFeeError && index === 0) { @@ -1498,6 +1648,7 @@ describe('BridgeController', function () { }, expect.any(AbortSignal), BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, null, @@ -1797,6 +1948,10 @@ describe('BridgeController', function () { ): ReturnType => { const [actionType, params] = args; + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + if (actionType === 'AccountsController:getAccountByAddress') { if (isEvmAccount) { return { @@ -1863,7 +2018,7 @@ describe('BridgeController', function () { asset: { unit: 'SOL', type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', - amount: expectedFees || '0', + amount: expectedFees ?? '0', fungible: true, }, }, @@ -1917,6 +2072,13 @@ describe('BridgeController', function () { // Loading state jest.advanceTimersByTime(201); await flushPromises(); + + // Wait for JWT token retrieval + if (!isEvmAccount) { + jest.advanceTimersToNextTimer(); + await flushPromises(); + } + expect(bridgeController.state).toStrictEqual( expect.objectContaining({ quotesLoadingStatus: RequestStatus.LOADING, @@ -2595,6 +2757,7 @@ describe('BridgeController', function () { UnifiedSwapBridgeEventName.StatusValidationFailed, { failures: ['Failed to submit tx'], + refresh_count: 0, }, ); expect(trackMetaMetricsFn).toHaveBeenCalledTimes(1); @@ -2641,6 +2804,7 @@ describe('BridgeController', function () { } as never; }, ); + bridgeController.setLocation(MetaMetricsSwapsEventSource.TrendingExplore); }); it('should not track the event if the account keyring type is not set', async () => { @@ -2739,6 +2903,7 @@ describe('BridgeController', function () { }, }, }); + (messengerMock.call as jest.Mock).mockResolvedValueOnce('AUTH_TOKEN'); (messengerMock.call as jest.Mock).mockReturnValueOnce(() => ({ address: '0x123', })); @@ -2774,14 +2939,14 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "aggIds": Array [ + [ + [ + { + "aggIds": [ "debridge", "socket", ], - "bridgeIds": Array [ + "bridgeIds": [ "bridge1", "bridge2", ], @@ -2799,6 +2964,7 @@ describe('BridgeController', function () { }, null, "extension", + "AUTH_TOKEN", [Function], "https://bridge.api.cx.metamask.io", "perps", @@ -2870,14 +3036,14 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "aggIds": Array [ + [ + [ + { + "aggIds": [ "debridge", "socket", ], - "bridgeIds": Array [ + "bridgeIds": [ "bridge1", "bridge2", ], @@ -2895,6 +3061,7 @@ describe('BridgeController', function () { }, null, "extension", + "AUTH_TOKEN", [Function], "https://bridge.api.cx.metamask.io", "perps", @@ -2932,9 +3099,9 @@ describe('BridgeController', function () { expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); expect(fetchBridgeQuotesSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { + [ + [ + { "destChainId": "1", "destTokenAddress": "0x1234", "gasIncluded": false, @@ -2948,6 +3115,7 @@ describe('BridgeController', function () { }, null, "extension", + "AUTH_TOKEN", [Function], "https://bridge.api.cx.metamask.io", null, @@ -2995,7 +3163,7 @@ describe('BridgeController', function () { bridgeController.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -3006,14 +3174,14 @@ describe('BridgeController', function () { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "assetExchangeRates": Object {}, + { + "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "srcTokenAddress": "0x0000000000000000000000000000000000000000", }, - "quotes": Array [], + "quotes": [], "quotesInitialLoadTime": null, "quotesLastFetched": null, "quotesLoadingStatus": null, @@ -3029,7 +3197,7 @@ describe('BridgeController', function () { bridgeController.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -3040,14 +3208,14 @@ describe('BridgeController', function () { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "assetExchangeRates": Object {}, + { + "assetExchangeRates": {}, "minimumBalanceForRentExemptionInLamports": "0", "quoteFetchError": null, - "quoteRequest": Object { + "quoteRequest": { "srcTokenAddress": "0x0000000000000000000000000000000000000000", }, - "quotes": Array [], + "quotes": [], "quotesInitialLoadTime": null, "quotesLastFetched": null, "quotesLoadingStatus": null, diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 4e57dac02d3..a2e8dc0c25d 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -58,6 +58,7 @@ import { } from './utils/fetch'; import { AbortReason, + MetaMetricsSwapsEventSource, MetricsActionType, UnifiedSwapBridgeEventName, } from './utils/metrics/constants'; @@ -166,6 +167,13 @@ export class BridgeController extends StaticIntervalPollingController - console.warn('Failed to fetch asset exchange rates', error), - ); - if (isValidQuoteRequest(updatedQuoteRequest)) { this.#quotesFirstFetched = Date.now(); const isSrcChainNonEVM = isNonEvmChainId(updatedQuoteRequest.srcChainId); @@ -347,6 +351,7 @@ export class BridgeController extends StaticIntervalPollingController => { const bridgeFeatureFlags = getBridgeFeatureFlags(this.messenger); + const jwt = await this.#getJwt(); // If featureId is specified, retrieve the quoteRequestOverrides for that featureId const quoteRequestOverrides = featureId ? bridgeFeatureFlags.quoteRequestOverrides?.[featureId] @@ -360,6 +365,7 @@ export class BridgeController extends StaticIntervalPollingController { + this.#location = location; + }; + resetState = (reason = AbortReason.ResetState) => { this.stopPollingForQuotes(reason); this.update((state) => { @@ -588,6 +606,10 @@ export class BridgeController extends StaticIntervalPollingController + console.warn('Failed to fetch asset exchange rates', error), + ); + this.trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.QuotesRequested, context, @@ -605,6 +627,8 @@ export class BridgeController extends StaticIntervalPollingController { /** @@ -737,6 +763,7 @@ export class BridgeController extends StaticIntervalPollingController => { + try { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return token; + } catch (error) { + console.error('Error getting JWT token for bridge-api request', error); + return undefined; + } + }; + readonly #getRequestMetadata = (): Omit< RequestMetadata, | 'stx_enabled' @@ -884,8 +923,10 @@ export class BridgeController extends StaticIntervalPollingController[EventName], ): CrossChainSwapsEventProperties => { + const clientProps = propertiesFromClient as Record; const baseProperties = { ...propertiesFromClient, + location: clientProps?.location ?? this.#location, action_type: MetricsActionType.SWAPBRIDGE_V1, }; switch (eventName) { @@ -956,6 +997,7 @@ export class BridgeController extends StaticIntervalPollingController { describe('selectExchangeRateByChainIdAndAddress', () => { const mockExchangeRateSources = { assetExchangeRates: { - 'eip155:1/erc20:0x123': { + [formatAddressToAssetId(MOCK_USDC_ADDRESS, '1')?.toLowerCase() ?? + MOCK_USDC_ADDRESS]: { exchangeRate: '2.5', usdExchangeRate: '1.5', }, @@ -34,13 +41,13 @@ describe('Bridge Selectors', () => { }, currencyRates: { ETH: { - conversionRate: 2468.12, - usdConversionRate: 1800, + conversionRate: 2468.12, // ETH rate in the user's selected currency + usdConversionRate: 1800, // ETH rate in USD }, }, marketData: { '0x1': { - '0xabc': { + [MOCK_MUSD_ADDRESS]: { price: 50 / 2468.12, currency: 'ETH', }, @@ -53,7 +60,7 @@ describe('Bridge Selectors', () => { }, } as unknown as BridgeAppState; - it('should return empty object if chainId or address is missing', () => { + it('should return empty object if chainId is missing', () => { expect( selectExchangeRateByChainIdAndAddress( mockExchangeRateSources, @@ -63,12 +70,15 @@ describe('Bridge Selectors', () => { ).toStrictEqual({}); expect( selectExchangeRateByChainIdAndAddress(mockExchangeRateSources, '1'), - ).toStrictEqual({}); + ).toStrictEqual({ + exchangeRate: '2468.12', + usdExchangeRate: '1800', + }); expect( selectExchangeRateByChainIdAndAddress( mockExchangeRateSources, undefined, - '0x123', + MOCK_USDC_ADDRESS, ), ).toStrictEqual({}); }); @@ -77,7 +87,7 @@ describe('Bridge Selectors', () => { const result = selectExchangeRateByChainIdAndAddress( mockExchangeRateSources, '1', - '0x123', + MOCK_USDC_ADDRESS, ); expect(result).toStrictEqual({ exchangeRate: '2.5', @@ -91,12 +101,51 @@ describe('Bridge Selectors', () => { SolScope.Mainnet, '789', ); + // usdExchangeRate = rate * (usdConversionRate / conversionRate) = 4.0 * (1800 / 2468.12) + expect(result).toStrictEqual({ + exchangeRate: '4.0', + usdExchangeRate: new BigNumber('4.0') + .times(new BigNumber(1800).div(2468.12)) + .toString(), + }); + }); + + it('should return undefined usdExchangeRate for Solana when currencyRates is empty', () => { + const result = selectExchangeRateByChainIdAndAddress( + { + ...mockExchangeRateSources, + currencyRates: {}, + } as unknown as BridgeAppState, + SolScope.Mainnet, + '789', + ); expect(result).toStrictEqual({ exchangeRate: '4.0', usdExchangeRate: undefined, }); }); + it('should return rate as usdExchangeRate for Solana when user currency is USD', () => { + const result = selectExchangeRateByChainIdAndAddress( + { + ...mockExchangeRateSources, + currencyRates: { + ETH: { + conversionRate: 1800, + usdConversionRate: 1800, + }, + }, + } as unknown as BridgeAppState, + SolScope.Mainnet, + '789', + ); + // When user currency is USD, conversionRate === usdConversionRate, ratio is 1 + expect(result).toStrictEqual({ + exchangeRate: '4.0', + usdExchangeRate: '4', + }); + }); + it('should handle EVM native asset rates', () => { const result = selectExchangeRateByChainIdAndAddress( mockExchangeRateSources, @@ -113,7 +162,7 @@ describe('Bridge Selectors', () => { const result = selectExchangeRateByChainIdAndAddress( mockExchangeRateSources, '1', - '0xabc', + MOCK_MUSD_ADDRESS.toLowerCase(), ); expect(result).toStrictEqual({ exchangeRate: '50.00000000000000162804', @@ -123,9 +172,11 @@ describe('Bridge Selectors', () => { }); describe('selectIsAssetExchangeRateInState', () => { + const assetId = + formatAddressToAssetId(MOCK_USDC_ADDRESS, '1')?.toLowerCase() ?? ''; const mockExchangeRateSources = { assetExchangeRates: { - 'eip155:1/erc20:0x123': { + [assetId]: { exchangeRate: '2.5', }, }, @@ -141,29 +192,36 @@ describe('Bridge Selectors', () => { ...mockExchangeRateSources, assetExchangeRates: { ...mockExchangeRateSources.assetExchangeRates, - 'eip155:1/erc20:0x123': { - ...mockExchangeRateSources.assetExchangeRates[ - 'eip155:1/erc20:0x123' - ], + [assetId]: { + // @ts-expect-error - ignore type error + ...mockExchangeRateSources.assetExchangeRates[assetId], usdExchangeRate: '1.5', }, }, }, '1', - '0x123', + MOCK_USDC_ADDRESS, ), ).toBe(true); }); it('should return false if USD exchange rate does not exist', () => { expect( - selectIsAssetExchangeRateInState(mockExchangeRateSources, '1', '0x123'), + selectIsAssetExchangeRateInState( + mockExchangeRateSources, + '1', + MOCK_USDC_ADDRESS, + ), ).toBe(false); }); it('should return false if exchange rate does not exist', () => { expect( - selectIsAssetExchangeRateInState(mockExchangeRateSources, '1', '0x456'), + selectIsAssetExchangeRateInState( + mockExchangeRateSources, + '1', + ETH_USDT_ADDRESS, + ), ).toBe(false); }); @@ -364,7 +422,14 @@ describe('Bridge Selectors', () => { ({ quotes: [ mockQuote, - { ...mockQuote, quote: { ...mockQuote.quote, requestId: '456' } }, + { + ...mockQuote, + quote: { + ...mockQuote.quote, + requestId: '456', + destTokenAmount: '2100000000000000000', + }, + }, ], quoteRequest: { srcChainId: '1', @@ -423,17 +488,78 @@ describe('Bridge Selectors', () => { }; it('should return sorted quotes with metadata', () => { - const result = selectBridgeQuotes(mockState, mockClientParams); + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + assetExchangeRates: { + [formatAddressToAssetId( + mockQuote.quote.srcAsset.address, + mockQuote.quote.srcChainId, + ) ?? '']: { + exchangeRate: '1980', + usdExchangeRate: '10', + }, + [formatAddressToAssetId( + mockQuote.quote.destAsset.address, + mockQuote.quote.destChainId, + ) ?? '']: { + exchangeRate: '200', + usdExchangeRate: '1', + }, + }, + }, + mockClientParams, + ); - expect(result.sortedQuotes).toHaveLength(2); - expect(result.sortedQuotes[0].quote.requestId).toMatchInlineSnapshot( - `"123"`, + // eslint-disable-next-line jest/no-restricted-matchers + expect(result).toMatchSnapshot(); + expect(result.sortedQuotes[0].cost.valueInCurrency).toBe('-419.985546'); + }); + + it('should use destTokenAmount to sort quotes if exchange rate is not available', () => { + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { ...mockState, assetExchangeRates: {}, marketData: {} }, + mockClientParams, + ); + + // eslint-disable-next-line jest/no-restricted-matchers + expect(result).toMatchSnapshot(); + expect(result.sortedQuotes[0].cost.valueInCurrency).toBeNull(); + expect(result.recommendedQuote?.quote.destTokenAmount).toBe( + '2100000000000000000', + ); + }); + + it('should use priceImpact to sort quotes if exchange rate is not available', () => { + const quotesWithPriceImpact = [ + { + ...mockQuote, + quote: { ...mockQuote.quote, priceData: { priceImpact: '0.01' } }, + }, + { + ...mockQuote, + quote: { ...mockQuote.quote, priceData: { priceImpact: '-0.02' } }, + }, + ]; + const { quotesInitialLoadTimeMs, quotesLastFetchedMs, ...result } = + selectBridgeQuotes( + { + ...mockState, + assetExchangeRates: {}, + marketData: {}, + quotes: quotesWithPriceImpact as unknown as QuoteResponse[], + }, + mockClientParams, + ); + + // eslint-disable-next-line jest/no-restricted-matchers + expect(result).toMatchSnapshot(); + expect(result.sortedQuotes[0].cost.valueInCurrency).toBeNull(); + expect(result.recommendedQuote?.quote.priceData?.priceImpact).toBe( + '-0.02', ); - expect(result.recommendedQuote).toBeDefined(); - expect(result.activeQuote).toBeDefined(); - expect(result.isLoading).toBe(false); - expect(result.quoteFetchError).toBeNull(); - expect(result.isQuoteGoingToRefresh).toBe(true); }); describe('returns swap metadata', () => { @@ -569,55 +695,55 @@ describe('Bridge Selectors', () => { expect(quote.gasIncluded).toBe(false); expect(isNativeAddress(quote.srcAsset.address)).toBe(true); expect(quoteMetadata).toMatchInlineSnapshot(` - Object { - "adjustedReturn": Object { + { + "adjustedReturn": { "usd": "10.513424894341876155230359150867612640256", "valueInCurrency": "8.995536137740000000254299423511757231474", }, - "cost": Object { + "cost": { "usd": "1.173955083193541475489640849132387359744", "valueInCurrency": "1.004463862259999726625700576488242768526", }, - "gasFee": Object { - "effective": Object { + "gasFee": { + "effective": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, - "max": Object { + "max": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "total": Object { + "total": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, }, "includedTxFees": null, - "minToTokenAmount": Object { + "minToTokenAmount": { "amount": "9.994389353314869106", "usd": "9.992709880792782347418849595400950831104", "valueInCurrency": "8.550000000000000000198810453356610924716", }, - "sentAmount": Object { + "sentAmount": { "amount": "0.018116598427479256", "usd": "11.68737997753541763072", "valueInCurrency": "9.99999999999999972688", }, "swapRate": "580.70558265713069471891", - "toTokenAmount": Object { + "toTokenAmount": { "amount": "10.520409845594599059", "usd": "10.518641979781876155230359150867612640256", "valueInCurrency": "9.000000000000000000254299423511757231474", }, - "totalMaxNetworkFee": Object { + "totalMaxNetworkFee": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "totalNetworkFee": Object { + "totalNetworkFee": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", @@ -652,55 +778,55 @@ describe('Bridge Selectors', () => { ...quoteMetadata } = sortedQuotes[0]; expect(quoteMetadata).toMatchInlineSnapshot(` - Object { - "adjustedReturn": Object { + { + "adjustedReturn": { "usd": "10.51342489434187625472", "valueInCurrency": "8.99553613774000008538", }, - "cost": Object { + "cost": { "usd": "1.173955083193541695202677292586583974912", "valueInCurrency": "1.004463862259999914617394921816007289298", }, - "gasFee": Object { - "effective": Object { + "gasFee": { + "effective": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, - "max": Object { + "max": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "total": Object { + "total": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, }, "includedTxFees": null, - "minToTokenAmount": Object { + "minToTokenAmount": { "amount": "0.015489691655494764", "usd": "9.99270988079278215168", "valueInCurrency": "8.54999999999999983272", }, - "sentAmount": Object { + "sentAmount": { "amount": "11.689344272882887843", "usd": "11.687379977535417949922677292586583974912", "valueInCurrency": "9.999999999999999999997394921816007289298", }, "swapRate": "0.00139485485277012214", - "toTokenAmount": Object { + "toTokenAmount": { "amount": "0.016304938584731331", "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "totalMaxNetworkFee": Object { + "totalMaxNetworkFee": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "totalNetworkFee": Object { + "totalNetworkFee": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", @@ -738,55 +864,55 @@ describe('Bridge Selectors', () => { ...quoteMetadata } = sortedQuotes[0]; expect(quoteMetadata).toMatchInlineSnapshot(` - Object { - "adjustedReturn": Object { + { + "adjustedReturn": { "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "cost": Object { + "cost": { "usd": "1.168737997753541695202677292586583974912", "valueInCurrency": "0.999999999999999914617394921816007289298", }, - "gasFee": Object { - "effective": Object { + "gasFee": { + "effective": { "amount": "0", "usd": "0", "valueInCurrency": "0", }, - "max": Object { + "max": { "amount": "0", "usd": "0", "valueInCurrency": "0", }, - "total": Object { + "total": { "amount": "0", "usd": "0", "valueInCurrency": "0", }, }, "includedTxFees": null, - "minToTokenAmount": Object { + "minToTokenAmount": { "amount": "0.015489691655494764", "usd": "9.99270988079278215168", "valueInCurrency": "8.54999999999999983272", }, - "sentAmount": Object { + "sentAmount": { "amount": "11.689344272882887843", "usd": "11.687379977535417949922677292586583974912", "valueInCurrency": "9.999999999999999999997394921816007289298", }, "swapRate": "0.00139485485277012214", - "toTokenAmount": Object { + "toTokenAmount": { "amount": "0.016304938584731331", "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "totalMaxNetworkFee": Object { + "totalMaxNetworkFee": { "amount": "0", "usd": "0", "valueInCurrency": "0", }, - "totalNetworkFee": Object { + "totalNetworkFee": { "amount": "0", "usd": "0", "valueInCurrency": "0", @@ -830,59 +956,59 @@ describe('Bridge Selectors', () => { ...quoteMetadata } = sortedQuotes[0]; expect(quoteMetadata).toMatchInlineSnapshot(` - Object { - "adjustedReturn": Object { + { + "adjustedReturn": { "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "cost": Object { + "cost": { "usd": "1.168737997753541695202677292586583974912", "valueInCurrency": "0.999999999999999914617394921816007289298", }, - "gasFee": Object { - "effective": Object { + "gasFee": { + "effective": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, - "max": Object { + "max": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "total": Object { + "total": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, }, - "includedTxFees": Object { + "includedTxFees": { "amount": "0.001", "usd": "0.64512", "valueInCurrency": "0.55198", }, - "minToTokenAmount": Object { + "minToTokenAmount": { "amount": "0.015489691655494764", "usd": "9.99270988079278215168", "valueInCurrency": "8.54999999999999983272", }, - "sentAmount": Object { + "sentAmount": { "amount": "11.689344272882887843", "usd": "11.687379977535417949922677292586583974912", "valueInCurrency": "9.999999999999999999997394921816007289298", }, "swapRate": "0.00139485485277012214", - "toTokenAmount": Object { + "toTokenAmount": { "amount": "0.016304938584731331", "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "totalMaxNetworkFee": Object { + "totalMaxNetworkFee": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "totalNetworkFee": Object { + "totalNetworkFee": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", @@ -926,59 +1052,59 @@ describe('Bridge Selectors', () => { ...quoteMetadata } = sortedQuotes[0]; expect(quoteMetadata).toMatchInlineSnapshot(` - Object { - "adjustedReturn": Object { + { + "adjustedReturn": { "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "cost": Object { + "cost": { "usd": "1.168737997753541695202677292586583974912", "valueInCurrency": "0.999999999999999914617394921816007289298", }, - "gasFee": Object { - "effective": Object { + "gasFee": { + "effective": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, - "max": Object { + "max": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "total": Object { + "total": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, }, - "includedTxFees": Object { + "includedTxFees": { "amount": "3", "usd": "1935.36", "valueInCurrency": "1655.94", }, - "minToTokenAmount": Object { + "minToTokenAmount": { "amount": "0.015489691655494764", "usd": "9.99270988079278215168", "valueInCurrency": "8.54999999999999983272", }, - "sentAmount": Object { + "sentAmount": { "amount": "11.689344272882887843", "usd": "11.687379977535417949922677292586583974912", "valueInCurrency": "9.999999999999999999997394921816007289298", }, "swapRate": "0.00139485485277012214", - "toTokenAmount": Object { + "toTokenAmount": { "amount": "0.016304938584731331", "usd": "10.51864197978187625472", "valueInCurrency": "9.00000000000000008538", }, - "totalMaxNetworkFee": Object { + "totalMaxNetworkFee": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "totalNetworkFee": Object { + "totalNetworkFee": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", @@ -1024,59 +1150,59 @@ describe('Bridge Selectors', () => { ...quoteMetadata } = sortedQuotes[0]; expect(quoteMetadata).toMatchInlineSnapshot(` - Object { - "adjustedReturn": Object { + { + "adjustedReturn": { "usd": "10.518641979781876096240273601395823616", "valueInCurrency": "8.999999999999999949780980627632791914", }, - "cost": Object { + "cost": { "usd": "1.168737997753541853682403691190760358912", "valueInCurrency": "1.000000000000000050216414294183215375298", }, - "gasFee": Object { - "effective": Object { + "gasFee": { + "effective": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, - "max": Object { + "max": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "total": Object { + "total": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", }, }, - "includedTxFees": Object { + "includedTxFees": { "amount": "1", "usd": "999.831958465623542784", "valueInCurrency": "855.479979591168903686", }, - "minToTokenAmount": Object { + "minToTokenAmount": { "amount": "0.009994389353314869", "usd": "9.992709880792782241436661998044855296", "valueInCurrency": "8.549999999999999909517932616692707134", }, - "sentAmount": Object { + "sentAmount": { "amount": "11.689344272882887843", "usd": "11.687379977535417949922677292586583974912", "valueInCurrency": "9.999999999999999999997394921816007289298", }, "swapRate": "0.00089999999999999999", - "toTokenAmount": Object { + "toTokenAmount": { "amount": "0.010520409845594599", "usd": "10.518641979781876096240273601395823616", "valueInCurrency": "8.999999999999999949780980627632791914", }, - "totalMaxNetworkFee": Object { + "totalMaxNetworkFee": { "amount": "0.000016174", "usd": "0.01043417088", "valueInCurrency": "0.00892772452", }, - "totalNetworkFee": Object { + "totalNetworkFee": { "amount": "0.000008087", "usd": "0.00521708544", "valueInCurrency": "0.00446386226", @@ -1113,8 +1239,20 @@ describe('Bridge Selectors', () => { sortOrder: SortOrder.ETA_ASC, }); - expect(resultCostAsc.sortedQuotes).toBeDefined(); - expect(resultEtaAsc.sortedQuotes).toBeDefined(); + expect(resultCostAsc.sortedQuotes.map((quote) => quote.quote.requestId)) + .toMatchInlineSnapshot(` + [ + "456", + "123", + ] + `); + expect(resultEtaAsc.sortedQuotes.map((quote) => quote.quote.requestId)) + .toMatchInlineSnapshot(` + [ + "123", + "456", + ] + `); }); it('should handle selected quote', () => { @@ -1381,7 +1519,7 @@ describe('Bridge Selectors', () => { '1': { isActiveSrc: true, isActiveDest: true, - stablecoins: ['0x123', '0x456'], + stablecoins: [MOCK_USDC_ADDRESS, '0x456'], }, '10': { isActiveSrc: true, @@ -1402,7 +1540,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', srcChainId: '10', destChainId: '10', @@ -1420,7 +1558,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', srcChainId: '1', destChainId: ChainId.SOLANA, @@ -1438,7 +1576,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', destChainId: '1', srcChainId: ChainId.SOLANA, @@ -1456,7 +1594,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', destChainId: ChainId.SOLANA, srcChainId: ChainId.SOLANA, @@ -1474,7 +1612,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x789', destChainId: '1', srcChainId: '1', @@ -1510,7 +1648,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', destChainId: '1', srcChainId: '1', @@ -1528,7 +1666,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', destChainId: '1', }, @@ -1545,7 +1683,7 @@ describe('Bridge Selectors', () => { }, } as never, { - srcTokenAddress: '0x123', + srcTokenAddress: MOCK_USDC_ADDRESS, destTokenAddress: '0x456', srcChainId: '1', }, diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 3c9a9da568d..a9638adc58a 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -35,6 +35,7 @@ import { } from './utils/bridge'; import { formatAddressToAssetId, + formatAddressToCaipReference, formatChainIdToCaip, formatChainIdToHex, } from './utils/caip-formatters'; @@ -123,12 +124,12 @@ export const selectBridgeFeatureFlags = createFeatureFlagsSelector( const getExchangeRateByChainIdAndAddress = ( exchangeRateSources: ExchangeRateControllerState, chainId?: GenericQuoteRequest['srcChainId'], - address?: GenericQuoteRequest['srcTokenAddress'], + rawAddress?: GenericQuoteRequest['srcTokenAddress'], ): ExchangeRate => { - if (!chainId || !address) { + if (!chainId) { return {}; } - // TODO return usd exchange rate if user has opted into metrics + const address = formatAddressToCaipReference(rawAddress ?? ''); const assetId = formatAddressToAssetId(address, chainId); if (!assetId) { return {}; @@ -140,18 +141,38 @@ const getExchangeRateByChainIdAndAddress = ( // If the asset exchange rate is available in the bridge controller, use it // This is defined if the token's rate is not available from the assets controllers const bridgeControllerRate = - assetExchangeRates?.[assetId] ?? - assetExchangeRates?.[assetId.toLowerCase() as CaipAssetType]; - if (bridgeControllerRate?.exchangeRate) { + assetExchangeRates?.[assetId.toLowerCase() as CaipAssetType] ?? + assetExchangeRates?.[assetId]; + if ( + bridgeControllerRate?.exchangeRate && + bridgeControllerRate?.usdExchangeRate + ) { return bridgeControllerRate; } // If the chain is a non-EVM chain, use the conversion rate from the multichain assets controller if (isNonEvmChainId(chainId)) { const multichainAssetExchangeRate = conversionRates?.[assetId]; if (multichainAssetExchangeRate) { + // The multichain rate is denominated in the user's selected currency. + // To get a USD rate, find the user's-currency-to-USD conversion factor from any EVM native currency rate. + const nativeCurrencyRate = Object.values(currencyRates ?? {}).find( + (rate) => rate?.conversionRate && rate?.usdConversionRate, + ); + const usersCurrencyToUsdRate = + nativeCurrencyRate?.conversionRate && + nativeCurrencyRate?.usdConversionRate + ? new BigNumber(nativeCurrencyRate.usdConversionRate).div( + nativeCurrencyRate.conversionRate, + ) + : undefined; + const usdExchangeRate = usersCurrencyToUsdRate + ? new BigNumber(multichainAssetExchangeRate.rate) + .times(usersCurrencyToUsdRate) + .toString() + : undefined; return { exchangeRate: multichainAssetExchangeRate.rate, - usdExchangeRate: undefined, + usdExchangeRate, }; } return {}; @@ -394,11 +415,28 @@ const selectSortedBridgeQuotes = createBridgeSelector( 'asc', ); default: + if (quotesWithMetadata.every((quote) => quote.cost.valueInCurrency)) { + return orderBy( + quotesWithMetadata, + ({ cost }) => Number(cost.valueInCurrency), + 'asc', + ); + } + if ( + quotesWithMetadata.every( + (quote) => quote.quote.priceData?.priceImpact, + ) + ) { + return orderBy( + quotesWithMetadata, + ({ quote }) => Number(quote.priceData?.priceImpact), + 'asc', + ); + } return orderBy( quotesWithMetadata, - ({ cost }) => - cost.valueInCurrency ? Number(cost.valueInCurrency) : 0, - 'asc', + ({ quote }) => Number(quote.destTokenAmount), + 'desc', ); } }, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index c51a353b2df..8a215683fb1 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -13,6 +13,7 @@ import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; +import type { AuthenticationControllerGetBearerToken } from '@metamask/profile-sync-controller/auth'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { Infer } from '@metamask/superstruct'; @@ -392,6 +393,7 @@ export type BridgeControllerEvents = BridgeControllerStateChangeEvent; export type AllowedActions = | AccountsControllerGetAccountByAddressAction + | AuthenticationControllerGetBearerToken | GetCurrencyRateState | TokenRatesControllerGetStateAction | MultichainAssetsRatesControllerGetStateAction diff --git a/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap b/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap index 127d7d17534..7257d220120 100644 --- a/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap +++ b/packages/bridge-controller/src/utils/__snapshots__/fetch.test.ts.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`fetch fetchBridgeQuotes should filter out malformed bridge quotes 2`] = ` -Array [ - Array [ +[ + [ "Quote validation failed", - Array [ + [ "unknown|quote", "lifi|quote.requestId", "lifi|quote.srcChainId", diff --git a/packages/bridge-controller/src/utils/caip-formatters.test.ts b/packages/bridge-controller/src/utils/caip-formatters.test.ts index 3140e48952b..b13a82a2ad3 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.test.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.test.ts @@ -266,5 +266,16 @@ describe('CAIP Formatters', () => { `eip155:43114/erc20:${tokenAddress}`, ); }); + + it('should return undefined when chainId is not provided', () => { + expect(formatAddressToAssetId('invalid-address')).toBeUndefined(); + }); + + it('should handle Tron addresses', () => { + const tokenAddress = 'TJ1234567890123456789012345678901234567890'; + expect(formatAddressToAssetId(tokenAddress, ChainId.TRON)).toBe( + 'tron:728126428/trc20:TJ1234567890123456789012345678901234567890', + ); + }); }); }); diff --git a/packages/bridge-controller/src/utils/caip-formatters.ts b/packages/bridge-controller/src/utils/caip-formatters.ts index 6bdb3386987..e9090f42ebb 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -1,7 +1,10 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { getAddress } from '@ethersproject/address'; import { AddressZero } from '@ethersproject/constants'; -import { convertHexToDecimal, toHex } from '@metamask/controller-utils'; +import { + convertHexToDecimal, + toChecksumHexAddress, +} from '@metamask/controller-utils'; import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { @@ -11,6 +14,7 @@ import { isCaipReference, isCaipAssetType, CaipAssetTypeStruct, + numberToHex, } from '@metamask/utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; @@ -48,7 +52,7 @@ export const formatChainIdToCaip = ( if (isTronChainId(chainId)) { return TrxScope.Mainnet; } - return toEvmCaipChainId(toHex(chainId)); + return toEvmCaipChainId(numberToHex(Number(chainId))); }; /** @@ -95,12 +99,12 @@ export const formatChainIdToHex = ( return chainId; } if (typeof chainId === 'number' || parseInt(chainId, 10)) { - return toHex(chainId); + return numberToHex(Number(chainId)); } if (isCaipChainId(chainId)) { const { reference } = parseCaipChainId(chainId); if (isCaipReference(reference) && !isNaN(Number(reference))) { - return toHex(reference); + return numberToHex(Number(reference)); } } // Throw an error if a non-evm chainId is passed to this function @@ -133,7 +137,7 @@ export const formatAddressToCaipReference = (address: string) => { }; /** - * Converts an address or assetId to a CaipAssetType + * Converts an address or assetId to a checksummed CaipAssetType * * @param addressOrAssetId - The address or assetId to convert * @param chainId - The chainId of the asset @@ -141,23 +145,39 @@ export const formatAddressToCaipReference = (address: string) => { */ export const formatAddressToAssetId = ( addressOrAssetId: Hex | CaipAssetType | string, - chainId: GenericQuoteRequest['srcChainId'], + chainId?: GenericQuoteRequest['srcChainId'], ): CaipAssetType | undefined => { if (isCaipAssetType(addressOrAssetId)) { return addressOrAssetId; } + if (!chainId) { + return undefined; + } + + const chainIdCaip = formatChainIdToCaip(chainId); if (isNativeAddress(addressOrAssetId)) { - return getNativeAssetForChainId(chainId).assetId; + return getNativeAssetForChainId(chainIdCaip).assetId; } - if (chainId === SolScope.Mainnet) { - return CaipAssetTypeStruct.create(`${chainId}/token:${addressOrAssetId}`); + if (chainIdCaip === SolScope.Mainnet) { + return CaipAssetTypeStruct.create( + `${chainIdCaip}/token:${addressOrAssetId}`, + ); + } + + if (chainIdCaip === TrxScope.Mainnet) { + return CaipAssetTypeStruct.create( + `${chainIdCaip}/trc20:${addressOrAssetId}`, + ); } // EVM assets if (!isStrictHexString(addressOrAssetId)) { return undefined; } + + // EVM assets + const checksummedAddress = toChecksumHexAddress(addressOrAssetId); return CaipAssetTypeStruct.create( - `${formatChainIdToCaip(chainId)}/erc20:${addressOrAssetId}`, + `${chainIdCaip}/erc20:${checksummedAddress}`, ); }; diff --git a/packages/bridge-controller/src/utils/fetch.test.ts b/packages/bridge-controller/src/utils/fetch.test.ts index 6eeb268eb9b..f786a996e4f 100644 --- a/packages/bridge-controller/src/utils/fetch.test.ts +++ b/packages/bridge-controller/src/utils/fetch.test.ts @@ -79,6 +79,7 @@ describe('fetch', () => { const result = await fetchBridgeTokens( '0xa', BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, '1.0.0', @@ -87,7 +88,11 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getTokens?chainId=10', { - headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + }, }, ); @@ -140,6 +145,7 @@ describe('fetch', () => { fetchBridgeTokens( '0xa', BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, '1.0.0', @@ -170,6 +176,7 @@ describe('fetch', () => { }, signal, BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, null, @@ -179,7 +186,11 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5', { - headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + }, signal, }, ); @@ -229,6 +240,7 @@ describe('fetch', () => { }, signal, BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, null, @@ -238,7 +250,11 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5', { - headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + }, signal, }, ); @@ -306,6 +322,7 @@ describe('fetch', () => { }, signal, BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, null, @@ -315,7 +332,11 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5', { - headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + }, signal, }, ); @@ -328,7 +349,7 @@ describe('fetch', () => { })), ); expect(result.validationFailures).toMatchInlineSnapshot(` - Array [ + [ "unknown|quote", "lifi|quote.requestId", "lifi|quote.srcChainId", @@ -378,6 +399,7 @@ describe('fetch', () => { }, signal, BridgeClientId.EXTENSION, + 'AUTH_TOKEN', mockFetchFn, BRIDGE_PROD_API_BASE_URL, FeatureId.PERPS, @@ -387,7 +409,11 @@ describe('fetch', () => { expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&destWalletAddress=0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&insufficientBal=false&resetApproval=false&gasIncluded=false&gasIncluded7702=false&slippage=0.5&fee=0&aggIds=socket%2Clifi&bridgeIds=bridge1%2Cbridge2', { - headers: { 'X-Client-Id': 'extension', 'Client-Version': '1.0.0' }, + headers: { + 'X-Client-Id': 'extension', + 'Client-Version': '1.0.0', + Authorization: 'Bearer AUTH_TOKEN', + }, signal, }, ); diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 5a391c777f3..b69d7e982e2 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StructError } from '@metamask/superstruct'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; @@ -18,16 +19,28 @@ import type { BridgeAsset, } from '../types'; -export const getClientHeaders = (clientId: string, clientVersion?: string) => ({ +export const getClientHeaders = ({ + clientId, + clientVersion, + jwt, +}: { + clientId: string; + clientVersion?: string; + jwt?: string; +}) => ({ 'X-Client-Id': clientId, + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), ...(clientVersion ? { 'Client-Version': clientVersion } : {}), }); /** * Returns a list of enabled (unblocked) tokens * + * @deprecated Use the popular and search bridge-api endpoints instead + * * @param chainId - The chain ID to fetch tokens for * @param clientId - The client ID for metrics + * @param jwt - The JWT token for authentication * @param fetchFn - The fetch function to use * @param bridgeApiBaseUrl - The base URL for the bridge API * @param clientVersion - The client version for metrics (optional) @@ -36,18 +49,18 @@ export const getClientHeaders = (clientId: string, clientVersion?: string) => ({ export async function fetchBridgeTokens( chainId: Hex | CaipChainId, clientId: string, + jwt: string | undefined, fetchFn: FetchFunction, bridgeApiBaseUrl: string, clientVersion?: string, ): Promise> { - // TODO make token api v2 call const url = `${bridgeApiBaseUrl}/getTokens?chainId=${formatChainIdToDec(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: getClientHeaders(clientId, clientVersion), + headers: getClientHeaders({ clientId, clientVersion, jwt }), }); const transformedTokens: Record = {}; @@ -107,6 +120,7 @@ const formatQueryParams = (request: GenericQuoteRequest): URLSearchParams => { * @param request - The quote request * @param signal - The abort signal * @param clientId - The client ID for metrics + * @param jwt - The JWT token for authentication * @param fetchFn - The fetch function to use * @param bridgeApiBaseUrl - The base URL for the bridge API * @param featureId - The feature ID to append to each quote @@ -117,6 +131,7 @@ export async function fetchBridgeQuotes( request: GenericQuoteRequest, signal: AbortSignal | null, clientId: string, + jwt: string | undefined, fetchFn: FetchFunction, bridgeApiBaseUrl: string, featureId: FeatureId | null, @@ -129,7 +144,7 @@ export async function fetchBridgeQuotes( const url = `${bridgeApiBaseUrl}/getQuote?${queryParams}`; const quotes: unknown[] = await fetchFn(url, { - headers: getClientHeaders(clientId, clientVersion), + headers: getClientHeaders({ clientId, clientVersion, jwt }), signal, }); @@ -142,10 +157,10 @@ export async function fetchBridgeQuotes( if (error instanceof StructError) { error.failures().forEach(({ branch, path }) => { const aggregatorId = - branch?.[0]?.quote?.bridgeId || - branch?.[0]?.quote?.bridges?.[0] || - (quoteResponse as QuoteResponse)?.quote?.bridgeId || - (quoteResponse as QuoteResponse)?.quote?.bridges?.[0] || + branch?.[0]?.quote?.bridgeId ?? + branch?.[0]?.quote?.bridges?.[0] ?? + (quoteResponse as QuoteResponse)?.quote?.bridgeId ?? + (quoteResponse as QuoteResponse)?.quote?.bridges?.[0] ?? 'unknown'; const pathString = path?.join('.') || 'unknown'; uniqueValidationFailures.add([aggregatorId, pathString].join('|')); @@ -200,7 +215,7 @@ const fetchAssetPricesForCurrency = async (request: { }); const url = `https://price.api.cx.metamask.io/v3/spot-prices?${queryParams}`; const priceApiResponse = (await fetchFn(url, { - headers: getClientHeaders(clientId, clientVersion), + headers: getClientHeaders({ clientId, clientVersion }), signal, })) as Record; if (!priceApiResponse || typeof priceApiResponse !== 'object') { @@ -274,6 +289,7 @@ export const fetchAssetPrices = async ( * @param request - The quote request * @param signal - The abort signal * @param clientId - The client ID for metrics + * @param jwt - The JWT token for authentication * @param bridgeApiBaseUrl - The base URL for the bridge API * @param serverEventHandlers - The server event handlers * @param serverEventHandlers.onValidationFailure - The function to handle validation failures @@ -287,6 +303,7 @@ export async function fetchBridgeQuoteStream( request: GenericQuoteRequest, signal: AbortSignal | undefined, clientId: string, + jwt: string | undefined, bridgeApiBaseUrl: string, serverEventHandlers: { onClose: () => void | Promise; @@ -320,11 +337,11 @@ export async function fetchBridgeQuoteStream( if (error instanceof StructError) { error.failures().forEach(({ branch, path }) => { const aggregatorId = - branch?.[0]?.quote?.bridgeId || - branch?.[0]?.quote?.bridges?.[0] || - (quoteResponse as QuoteResponse)?.quote?.bridgeId || - (quoteResponse as QuoteResponse)?.quote?.bridges?.[0] || - 'unknown'; + branch?.[0]?.quote?.bridgeId ?? + branch?.[0]?.quote?.bridges?.[0] ?? + (quoteResponse as QuoteResponse)?.quote?.bridgeId ?? + ((quoteResponse as QuoteResponse)?.quote?.bridges?.[0] || + ('unknown' as string)); const pathString = path?.join('.') || 'unknown'; uniqueValidationFailures.add([aggregatorId, pathString].join('|')); }); @@ -343,14 +360,14 @@ export async function fetchBridgeQuoteStream( const urlStream = `${bridgeApiBaseUrl}/getQuoteStream?${queryParams}`; await fetchServerEvents(urlStream, { headers: { - ...getClientHeaders(clientId, clientVersion), + ...getClientHeaders({ clientId, clientVersion, jwt }), 'Content-Type': 'text/event-stream', }, signal, onMessage, - onError: (e) => { + onError: (error) => { // Rethrow error to prevent silent fetch failures - throw e; + throw error; }, onClose: async () => { await serverEventHandlers.onClose(); diff --git a/packages/bridge-controller/src/utils/metrics/constants.ts b/packages/bridge-controller/src/utils/metrics/constants.ts index 4b4d94e9461..ad5a730db96 100644 --- a/packages/bridge-controller/src/utils/metrics/constants.ts +++ b/packages/bridge-controller/src/utils/metrics/constants.ts @@ -21,6 +21,7 @@ export enum UnifiedSwapBridgeEventName { AssetDetailTooltipClicked = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Asset Detail Tooltip Clicked`, QuotesValidationFailed = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Quotes Failed Validation`, StatusValidationFailed = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Status Failed Validation`, + AssetPickerOpened = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Asset Picker Opened`, PollingStatusUpdated = `${UNIFIED_SWAP_BRIDGE_EVENT_CATEGORY} Polling Status Updated`, } @@ -37,11 +38,15 @@ export enum AbortReason { } /** - * @deprecated remove this event property + * Identifies the entry point from which the user initiated a swap or bridge flow. + * Included as the `location` property on every Unified SwapBridge event so + * analytics can trace the user's origin regardless of where they are in the flow. */ export enum MetaMetricsSwapsEventSource { MainView = 'Main View', TokenView = 'Token View', + TrendingExplore = 'Trending Explore', + Rewards = 'Rewards', } export enum MetricsActionType { diff --git a/packages/bridge-controller/src/utils/metrics/properties.test.ts b/packages/bridge-controller/src/utils/metrics/properties.test.ts index 3f8202d69de..7c36804a0ac 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.test.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.test.ts @@ -358,9 +358,8 @@ describe('properties', () => { }, }); - expect(result).toMatchInlineSnapshot( - ` - Object { + expect(result).toMatchInlineSnapshot(` + { "best_quote_provider": "bridge2_bridge2", "can_submit": false, "gas_included": false, @@ -371,10 +370,9 @@ describe('properties', () => { "usd_balance_source": 0, "usd_quoted_gas": 0, "usd_quoted_return": 0, - "warnings": Array [], + "warnings": [], } - `, - ); + `); }); }); }); diff --git a/packages/bridge-controller/src/utils/metrics/types.ts b/packages/bridge-controller/src/utils/metrics/types.ts index b78dd35c520..5ea1b7a0681 100644 --- a/packages/bridge-controller/src/utils/metrics/types.ts +++ b/packages/bridge-controller/src/utils/metrics/types.ts @@ -83,12 +83,15 @@ export type QuoteWarning = | 'tx_alert'; /** - * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called + * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called. + * This is the base type without the `location` property which is added to all events + * via the RequiredEventContextFromClient mapped type. */ -export type RequiredEventContextFromClient = { - [UnifiedSwapBridgeEventName.ButtonClicked]: { - location: MetaMetricsSwapsEventSource; - } & Pick; +type RequiredEventContextFromClientBase = { + [UnifiedSwapBridgeEventName.ButtonClicked]: Pick< + RequestParams, + 'token_symbol_source' | 'token_symbol_destination' + >; // When type is object, the payload can be anything [UnifiedSwapBridgeEventName.PageViewed]: object; [UnifiedSwapBridgeEventName.InputChanged]: { @@ -213,6 +216,10 @@ export type RequiredEventContextFromClient = { }; [UnifiedSwapBridgeEventName.StatusValidationFailed]: { failures: string[]; + refresh_count: number; + }; + [UnifiedSwapBridgeEventName.AssetPickerOpened]: { + location: 'source' | 'destination'; }; [UnifiedSwapBridgeEventName.PollingStatusUpdated]: TradeData & Pick & @@ -230,6 +237,18 @@ export type RequiredEventContextFromClient = { }; }; +/** + * Properties that are required to be provided when trackUnifiedSwapBridgeEvent is called. + * This combines the event-specific properties from RequiredEventContextFromClientBase + * with an optional `location` property. When `location` is omitted, the controller + * falls back to the value stored via `setLocation()` (defaults to MainView). + */ +export type RequiredEventContextFromClient = { + [K in keyof RequiredEventContextFromClientBase]: RequiredEventContextFromClientBase[K] & { + location?: MetaMetricsSwapsEventSource; + }; +}; + /** * Properties that can be derived from the bridge controller state */ @@ -281,9 +300,8 @@ export type EventPropertiesFromControllerState = { [UnifiedSwapBridgeEventName.QuotesValidationFailed]: RequestParams & { refresh_count: number; }; - [UnifiedSwapBridgeEventName.StatusValidationFailed]: RequestParams & { - refresh_count: number; - }; + [UnifiedSwapBridgeEventName.StatusValidationFailed]: RequestParams; + [UnifiedSwapBridgeEventName.AssetPickerOpened]: null; [UnifiedSwapBridgeEventName.PollingStatusUpdated]: null; }; @@ -296,6 +314,7 @@ export type CrossChainSwapsEventProperties< > = | { action_type: MetricsActionType; + location: MetaMetricsSwapsEventSource; } | Pick[T] | Pick[T]; diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 9bb0e7858f2..be1f3d56b46 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -441,18 +441,18 @@ describe('Quote Metadata Utils', () => { }); expect(result).toMatchInlineSnapshot(` - Object { - "effective": Object { + { + "effective": { "amount": "0.003584", "usd": "5.376", "valueInCurrency": "7.168", }, - "max": Object { + "max": { "amount": "0.006934", "usd": "10.401", "valueInCurrency": "13.868", }, - "total": Object { + "total": { "amount": "0.003584", "usd": "5.376", "valueInCurrency": "7.168", @@ -477,18 +477,18 @@ describe('Quote Metadata Utils', () => { }); expect(result).toMatchInlineSnapshot(` - Object { - "effective": Object { + { + "effective": { "amount": "0.00166", "usd": "2.49", "valueInCurrency": "3.32", }, - "max": Object { + "max": { "amount": "0.006934", "usd": "10.401", "valueInCurrency": "13.868", }, - "total": Object { + "total": { "amount": "0.003584", "usd": "5.376", "valueInCurrency": "7.168", diff --git a/packages/bridge-controller/tsconfig.json b/packages/bridge-controller/tsconfig.json index 5a49183c433..61896deae83 100644 --- a/packages/bridge-controller/tsconfig.json +++ b/packages/bridge-controller/tsconfig.json @@ -14,6 +14,7 @@ { "path": "../gas-fee-controller" }, { "path": "../assets-controllers" }, { "path": "../multichain-network-controller" }, + { "path": "../profile-sync-controller" }, { "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "./src", "./tests"] diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index af8d07e5b18..38c2565d73e 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,10 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [67.0.0] + +### Added + +- **BREAKING:** Retrieve JWT token from the ProfileSyncController and include it in bridge request headers ([#7955](https://github.com/MetaMask/core/pull/7955)) +- Bump `@metamask/bridge-controller` from `^66.2.0` to `^67.0.0` ([#7961](https://github.com/MetaMask/core/pull/7961)) + +## [66.1.0] + +### Added + +- Added `location` property to `BridgeHistoryItem` to persist the entry point across the transaction lifecycle ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Added `location` parameter to `StartPollingForBridgeTxStatusArgs` ([#7931](https://github.com/MetaMask/core/pull/7931)) +- Added optional `location` parameter to `submitTx` method ([#7931](https://github.com/MetaMask/core/pull/7931)) + +### Changed + +- All post-submission events (`Submitted`, `Completed`, `Failed`, `PollingStatusUpdated`, `StatusValidationFailed`) now include the `location` property from `BridgeHistoryItem` ([#7931](https://github.com/MetaMask/core/pull/7931)) + +### Fixed + +- Fix `usd_amount_source` default value in EVM transaction metrics properties from `100` to `0` ([#7899](https://github.com/MetaMask/core/pull/7899)) + +## [66.0.2] + +### Changed + +- Bump `@metamask/bridge-controller` from `^66.1.0` to `^66.1.1 ([#7910](https://github.com/MetaMask/core/pull/7910)) + +## [66.0.1] + ### Changed -- Bump `@metamask/bridge-controller` from `^65.3.0` to `^66.0.0` ([#7862](https://github.com/MetaMask/core/pull/7862)) -- Bump `@metamask/transaction-controller` from `^62.14.0` to `^62.16.0` ([#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/bridge-controller` from `^65.3.0` to `^66.1.0` ([#7862](https://github.com/MetaMask/core/pull/7862)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/transaction-controller` from `^62.14.0` to `^62.17.0` ([#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) ## [66.0.0] @@ -936,7 +968,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@66.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@67.0.0...HEAD +[67.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@66.1.0...@metamask/bridge-status-controller@67.0.0 +[66.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@66.0.2...@metamask/bridge-status-controller@66.1.0 +[66.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@66.0.1...@metamask/bridge-status-controller@66.0.2 +[66.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@66.0.0...@metamask/bridge-status-controller@66.0.1 [66.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@65.0.1...@metamask/bridge-status-controller@66.0.0 [65.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@65.0.0...@metamask/bridge-status-controller@65.0.1 [65.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@64.4.5...@metamask/bridge-status-controller@65.0.0 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index c6db78ba578..49c9a852f4e 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-status-controller", - "version": "66.0.0", + "version": "67.0.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "MetaMask", @@ -48,16 +48,17 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", - "@metamask/bridge-controller": "^66.0.0", + "@metamask/bridge-controller": "^67.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/gas-fee-controller": "^26.0.2", "@metamask/network-controller": "^29.0.0", "@metamask/polling-controller": "^16.0.2", + "@metamask/profile-sync-controller": "^27.1.0", "@metamask/snaps-controllers": "^17.2.0", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "uuid": "^8.3.2" @@ -65,14 +66,14 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "lodash": "^4.17.21", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 9d6865119dc..29bbc53bad7 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`BridgeStatusController constructor rehydrates the tx history state 1`] = ` -Object { - "bridgeTxMetaId1": Object { +{ + "bridgeTxMetaId1": { "account": "0xaccount1", "actionId": undefined, "approvalTxId": undefined, @@ -14,20 +14,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": undefined, "originalTransactionId": "bridgeTxMetaId1", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": undefined, "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": undefined, }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -41,10 +42,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -60,7 +61,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -74,11 +75,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -91,13 +92,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -115,15 +116,15 @@ Object { }, "slippagePercentage": 0, "startTime": 1729964825189, - "status": Object { - "destChain": Object { + "status": { + "destChain": { "chainId": 10, - "token": Object {}, + "token": {}, }, - "srcChain": Object { + "srcChain": { "amount": "991250000000000", "chainId": 42161, - "token": Object { + "token": { "address": "0x0000000000000000000000000000000000000000", "chainId": 42161, "coinKey": "ETH", @@ -145,12 +146,12 @@ Object { `; exports[`BridgeStatusController constructor should setup correctly 1`] = ` -Array [ - Array [ +[ + [ "TransactionController:transactionFailed", [Function], ], - Array [ + [ "TransactionController:transactionConfirmed", [Function], ], @@ -158,18 +159,21 @@ Array [ `; exports[`BridgeStatusController startPollingForBridgeTxStatus emits bridgeTransactionFailed event when the status response is failed 1`] = ` -Array [ - Array [ +[ + [ + "AuthenticationController:getBearerToken", + ], + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 105213.34261666666, "allowance_reset_transaction": undefined, @@ -181,12 +185,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": false, @@ -206,8 +211,8 @@ Array [ `; exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx history state 1`] = ` -Object { - "bridgeTxMetaId1": Object { +{ + "bridgeTxMetaId1": { "account": "0xaccount1", "actionId": undefined, "approvalTxId": undefined, @@ -217,20 +222,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": undefined, "originalTransactionId": "bridgeTxMetaId1", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": undefined, "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": undefined, }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -244,10 +250,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -263,7 +269,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -277,11 +283,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -294,13 +300,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -318,8 +324,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1729964825189, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xsrcTxHash1", }, @@ -332,18 +338,21 @@ Object { `; exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when the status response is complete 1`] = ` -Array [ - Array [ +[ + [ + "AuthenticationController:getBearerToken", + ], + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Completed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 105213.34261666666, "allowance_reset_transaction": undefined, @@ -355,12 +364,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": true, @@ -380,20 +390,20 @@ Array [ `; exports[`BridgeStatusController startPollingForBridgeTxStatus stops polling when the status response is complete 2`] = ` -Array [ +[ "BridgeStatusController:destinationTransactionCompleted", "eip155:10/slip44:60", ] `; exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHardwareWalletDelay for hardware wallet on mobile 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -401,7 +411,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -410,7 +420,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHardwareWalletDelay for hardware wallet on mobile 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -420,20 +430,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -447,10 +458,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -466,7 +477,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -480,11 +491,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -497,13 +508,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -521,8 +532,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -534,20 +545,20 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHardwareWalletDelay for hardware wallet on mobile 3`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -555,6 +566,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": true, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -567,42 +579,42 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHardwareWalletDelay for hardware wallet on mobile 4`] = ` -Array [ - Array [ - Object { - "data": Object { +[ + [ + { + "data": { "srcChainId": "eip155:42161", "stxEnabled": false, }, @@ -610,9 +622,9 @@ Array [ }, [Function], ], - Array [ - Object { - "data": Object { + [ + { + "data": { "srcChainId": "eip155:42161", "stxEnabled": false, }, @@ -624,13 +636,13 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -638,7 +650,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -647,7 +659,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -657,20 +669,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -684,10 +697,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -703,7 +716,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -717,11 +730,11 @@ Object { }, "srcChainId": 8453, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -734,13 +747,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -758,8 +771,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 8453, "txHash": "0xevmTxHash", }, @@ -771,20 +784,20 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 3`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "otherAccount", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:8453", @@ -792,6 +805,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -804,42 +818,42 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 4`] = ` -Array [ - Array [ - Object { - "data": Object { +[ + [ + { + "data": { "srcChainId": "eip155:8453", "stxEnabled": false, }, @@ -847,9 +861,9 @@ Array [ }, [Function], ], - Array [ - Object { - "data": Object { + [ + { + "data": { "srcChainId": "eip155:8453", "stxEnabled": false, }, @@ -861,13 +875,13 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -875,7 +889,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -884,7 +898,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -894,20 +908,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -921,10 +936,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -940,7 +955,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -954,11 +969,11 @@ Object { }, "srcChainId": 59144, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -971,13 +986,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -995,8 +1010,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 59144, "txHash": "0xevmTxHash", }, @@ -1008,20 +1023,20 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 3`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "otherAccount", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:59144", @@ -1029,6 +1044,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -1041,42 +1057,42 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 4`] = ` -Array [ - Array [ - Object { - "data": Object { +[ + [ + { + "data": { "srcChainId": "eip155:59144", "stxEnabled": false, }, @@ -1084,9 +1100,9 @@ Array [ }, [Function], ], - Array [ - Object { - "data": Object { + [ + { + "data": { "srcChainId": "eip155:59144", "stxEnabled": false, }, @@ -1098,14 +1114,14 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 1`] = ` -Object { +{ "batchId": "batchId1", "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -1113,7 +1129,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -1122,7 +1138,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": undefined, "approvalTxId": undefined, @@ -1132,20 +1148,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": true, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1159,10 +1176,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1178,7 +1195,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1192,11 +1209,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1209,13 +1226,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1233,8 +1250,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -1246,12 +1263,12 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 3`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "networkClientId": "arbitrum", - "transactionParams": Object { + "transactionParams": { "data": "0xdata", "from": "0xaccount1", "gas": "21000", @@ -1264,9 +1281,9 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 4`] = ` -Array [ - Array [ - Object { +[ + [ + { "disable7702": true, "from": "0xaccount1", "isGasFeeIncluded": false, @@ -1274,13 +1291,13 @@ Array [ "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, - "transactions": Array [ - Object { - "assetsFiatValues": Object { + "transactions": [ + { + "assetsFiatValues": { "receiving": "2.9999", "sending": "2.00", }, - "params": Object { + "params": { "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", @@ -1298,11 +1315,11 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 5`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", - Object { + { "best_quote_provider": "lifi_across", "can_submit": true, "gas_included": false, @@ -1313,19 +1330,19 @@ Array [ "usd_balance_source": 0, "usd_quoted_gas": 2.5778, "usd_quoted_return": 0.134214, - "warnings": Array [ + "warnings": [ "low_return", ], }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -1333,6 +1350,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -1345,31 +1363,31 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -1377,7 +1395,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -1386,7 +1404,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -1396,20 +1414,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1423,10 +1442,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1442,7 +1461,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1456,11 +1475,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1473,13 +1492,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1497,8 +1516,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -1510,20 +1529,20 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 3`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "otherAccount", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -1531,6 +1550,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -1543,42 +1563,42 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 4`] = ` -Array [ - Array [ - Object { - "data": Object { +[ + [ + { + "data": { "srcChainId": "eip155:42161", "stxEnabled": false, }, @@ -1586,9 +1606,9 @@ Array [ }, [Function], ], - Array [ - Object { - "data": Object { + [ + { + "data": { "srcChainId": "eip155:42161", "stxEnabled": false, }, @@ -1600,13 +1620,13 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -1614,7 +1634,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -1623,7 +1643,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -1633,20 +1653,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1660,10 +1681,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1679,7 +1700,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1693,11 +1714,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1710,13 +1731,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1734,8 +1755,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -1747,20 +1768,20 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 3`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -1768,6 +1789,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -1780,42 +1802,42 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 4`] = ` -Array [ - Array [ - Object { - "data": Object { +[ + [ + { + "data": { "srcChainId": "eip155:42161", "stxEnabled": false, }, @@ -1823,9 +1845,9 @@ Array [ }, [Function], ], - Array [ - Object { - "data": Object { + [ + { + "data": { "srcChainId": "eip155:42161", "stxEnabled": false, }, @@ -1837,13 +1859,13 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -1851,7 +1873,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -1860,7 +1882,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -1870,20 +1892,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1897,10 +1920,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1916,7 +1939,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1930,11 +1953,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -1947,13 +1970,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -1971,8 +1994,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -1984,12 +2007,12 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 3`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0x1", "networkClientId": "arbitrum-client-id", - "transactionParams": Object { + "transactionParams": { "chainId": "0x1", "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", "from": "0xaccount1", @@ -2000,11 +2023,11 @@ Array [ }, }, ], - Array [ - Object { + [ + { "chainId": "0xa4b1", "networkClientId": "arbitrum-client-id", - "transactionParams": Object { + "transactionParams": { "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", @@ -2015,11 +2038,11 @@ Array [ }, }, ], - Array [ - Object { + [ + { "chainId": "0xa4b1", "networkClientId": "arbitrum", - "transactionParams": Object { + "transactionParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2034,9 +2057,9 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 4`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0x1", "data": "0x095ea7b3000000000000000000000000881d40237659c251811cec9c364ef91dc08d300c0000000000000000000000000000000000000000000000000000000000000000", "from": "0xaccount1", @@ -2047,7 +2070,7 @@ Array [ "to": "0xtokenContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", @@ -2055,8 +2078,8 @@ Array [ "type": "bridgeApproval", }, ], - Array [ - Object { + [ + { "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", @@ -2067,7 +2090,7 @@ Array [ "to": "0xtokenContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", @@ -2075,8 +2098,8 @@ Array [ "type": "bridgeApproval", }, ], - Array [ - Object { + [ + { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2087,7 +2110,7 @@ Array [ "to": "0xbridgeContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum", "origin": "metamask", @@ -2099,20 +2122,20 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 5`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -2120,6 +2143,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -2132,59 +2156,59 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0x1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2192,7 +2216,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -2201,7 +2225,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": "test-approval-tx-id", @@ -2211,20 +2235,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -2238,10 +2263,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2257,7 +2282,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2271,11 +2296,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -2288,13 +2313,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2312,8 +2337,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -2325,9 +2350,9 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 3`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", @@ -2338,7 +2363,7 @@ Array [ "to": "0xtokenContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", @@ -2346,8 +2371,8 @@ Array [ "type": "bridgeApproval", }, ], - Array [ - Object { + [ + { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2358,7 +2383,7 @@ Array [ "to": "0xbridgeContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum", "origin": "metamask", @@ -2370,20 +2395,20 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 4`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "otherAccount", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -2391,6 +2416,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -2403,45 +2429,45 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2449,7 +2475,7 @@ Object { "to": "0xbridgeContract", "value": "0x0", }, - "txReceipt": Object { + "txReceipt": { "effectiveGasPrice": "0x1880a", "gasUsed": "0x2c92a", }, @@ -2458,7 +2484,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": undefined, @@ -2468,20 +2494,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000032", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -2495,10 +2522,10 @@ Object { }, "destChainId": 10, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2514,7 +2541,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2528,11 +2555,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -2545,13 +2572,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2569,8 +2596,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -2582,12 +2609,12 @@ Object { `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 3`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "networkClientId": "arbitrum", - "transactionParams": Object { + "transactionParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2602,9 +2629,9 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 4`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2615,7 +2642,7 @@ Array [ "to": "0xbridgeContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum", "origin": "metamask", @@ -2627,20 +2654,20 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 5`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -2648,6 +2675,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -2660,27 +2688,27 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx fails 1`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", @@ -2691,7 +2719,7 @@ Array [ "to": "0xtokenContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", @@ -2703,20 +2731,20 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx fails 2`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -2724,6 +2752,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -2736,24 +2765,24 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx meta does not exist 1`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "data": "0xapprovalData", "from": "0xaccount1", @@ -2764,7 +2793,7 @@ Array [ "to": "0xtokenContract", "value": "0x0", }, - Object { + { "actionId": "1234567890.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", @@ -2776,20 +2805,20 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM bridge should throw an error if approval tx meta does not exist 2`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", @@ -2797,6 +2826,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0.25, @@ -2809,31 +2839,31 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM swap should estimate gas when gasIncluded is false and STX is off 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2846,14 +2876,14 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 1`] = ` -Object { +{ "batchId": "batchId1", "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -2866,7 +2896,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": undefined, "approvalTxId": undefined, @@ -2876,20 +2906,21 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": true, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -2903,10 +2934,10 @@ Object { }, "destChainId": 42161, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2922,7 +2953,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2936,11 +2967,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -2953,13 +2984,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -2977,8 +3008,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -2990,12 +3021,12 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 3`] = ` -Array [ - Array [ - Object { +[ + [ + { "chainId": "0xa4b1", "networkClientId": "arbitrum", - "transactionParams": Object { + "transactionParams": { "data": "0xapprovalData", "from": "0xaccount1", "gas": "21000", @@ -3004,11 +3035,11 @@ Array [ }, }, ], - Array [ - Object { + [ + { "chainId": "0xa4b1", "networkClientId": "arbitrum", - "transactionParams": Object { + "transactionParams": { "data": "0xdata", "from": "0xaccount1", "gas": "21000", @@ -3021,9 +3052,9 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 4`] = ` -Array [ - Array [ - Object { +[ + [ + { "disable7702": true, "from": "0xaccount1", "isGasFeeIncluded": false, @@ -3031,9 +3062,9 @@ Array [ "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, - "transactions": Array [ - Object { - "params": Object { + "transactions": [ + { + "params": { "data": "0xapprovalData", "from": "0xaccount1", "gas": "0x5208", @@ -3044,12 +3075,12 @@ Array [ }, "type": "swapApproval", }, - Object { - "assetsFiatValues": Object { + { + "assetsFiatValues": { "receiving": "2.9999", "sending": "2.00", }, - "params": Object { + "params": { "data": "0xdata", "from": "0xaccount1", "gas": "0x5208", @@ -3067,20 +3098,20 @@ Array [ `; exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 5`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", @@ -3088,6 +3119,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0, @@ -3100,34 +3132,34 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with approval 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -3140,13 +3172,13 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with featureId 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -3159,55 +3191,55 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with featureId 2`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -3220,7 +3252,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": undefined, @@ -3230,20 +3262,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000032", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -3257,10 +3290,10 @@ Object { }, "destChainId": 42161, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -3276,7 +3309,7 @@ Object { }, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -3290,11 +3323,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -3307,13 +3340,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -3331,8 +3364,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -3344,20 +3377,20 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 3`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", @@ -3365,6 +3398,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0, @@ -3377,32 +3411,32 @@ Array [ "usd_quoted_return": 0, }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "NetworkController:findNetworkClientIdByChainId", "0xa4b1", ], - Array [ + [ "GasFeeController:getState", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: EVM swap should use batch path when gasIncluded7702 is true regardless of STX setting 1`] = ` -Object { +{ "batchId": "batchId1", "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -3415,13 +3449,13 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 1`] = ` -Object { +{ "chainId": "0xa4b1", "hash": "0xevmTxHash", "id": "test-tx-id", "status": "unapproved", "time": 1234567890, - "txParams": Object { + "txParams": { "chainId": "0xa4b1", "data": "0xdata", "from": "0xaccount1", @@ -3434,7 +3468,7 @@ Object { `; exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 2`] = ` -Object { +{ "account": "0xaccount1", "actionId": "1234567890.456", "approvalTxId": undefined, @@ -3444,20 +3478,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "test-tx-id", - "pricingData": Object { + "pricingData": { "amountSent": "1.234", "amountSentInUsd": "1.01", "quotedGasAmount": ".00055", "quotedGasInUsd": "2.5778", "quotedReturnInUsd": "0.134214", }, - "quote": Object { + "quote": { "bridgeId": "lifi", - "bridges": Array [ + "bridges": [ "across", ], - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -3471,10 +3506,10 @@ Object { }, "destChainId": 42161, "destTokenAmount": "990654755978612", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "8750000000000", - "asset": Object { + "asset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -3487,7 +3522,7 @@ Object { "symbol": "ETH", }, }, - "txFee": Object { + "txFee": { "maxFeePerGas": "1395348", "maxPriorityFeePerGas": "1000001", }, @@ -3496,7 +3531,7 @@ Object { "gasIncluded7702": false, "minDestTokenAmount": "941000000000000", "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -3510,11 +3545,11 @@ Object { }, "srcChainId": 42161, "srcTokenAmount": "991250000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "990654755978612", - "destAsset": Object { + "destAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:10/slip44:60", "chainId": 10, @@ -3527,13 +3562,13 @@ Object { "symbol": "ETH", }, "destChainId": 10, - "protocol": Object { + "protocol": { "displayName": "Across", "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", "name": "across", }, "srcAmount": "991250000000000", - "srcAsset": Object { + "srcAsset": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -3551,8 +3586,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 42161, "txHash": "0xevmTxHash", }, @@ -3564,20 +3599,20 @@ Object { `; exports[`BridgeStatusController submitTx: Solana bridge should handle snap controller errors 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3585,6 +3620,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_test-bridge", "quoted_time_minutes": 5, @@ -3597,16 +3633,16 @@ Array [ "usd_quoted_return": 985, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -3615,10 +3651,10 @@ Array [ "snapId": "test-snap", }, ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3627,6 +3663,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_test-bridge", "quoted_time_minutes": 5, @@ -3643,20 +3680,20 @@ Array [ `; exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3664,6 +3701,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_test-bridge", "quoted_time_minutes": 5, @@ -3676,16 +3714,16 @@ Array [ "usd_quoted_return": 985, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -3698,7 +3736,7 @@ Array [ `; exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 2`] = ` -Object { +{ "approvalTxId": undefined, "chainId": "0x416edef1601be", "destinationChainId": "0x1", @@ -3719,7 +3757,7 @@ Object { "status": "submitted", "swapTokenValue": "1", "time": 1234567890, - "txParams": Object { + "txParams": { "data": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", "from": "0x123...", }, @@ -3728,13 +3766,13 @@ Object { `; exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 3`] = ` -Object { +{ "bridgeTxMetaId": "signature", } `; exports[`BridgeStatusController submitTx: Solana bridge should successfully submit a transaction 4`] = ` -Object { +{ "account": "0x123...", "actionId": undefined, "approvalTxId": undefined, @@ -3744,20 +3782,21 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "signature", - "pricingData": Object { + "pricingData": { "amountSent": "1", "amountSentInUsd": "100", "quotedGasAmount": "0.05", "quotedGasInUsd": "5", "quotedReturnInUsd": "1000", }, - "quote": Object { + "quote": { "bridgeId": "test-bridge", - "bridges": Array [ + "bridges": [ "test-bridge", ], - "destAsset": Object { + "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", "chainId": 1, @@ -3767,10 +3806,10 @@ Object { }, "destChainId": 1, "destTokenAmount": "0.5", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "1000000", - "asset": Object { + "asset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", "chainId": 1151111081099710, @@ -3782,7 +3821,7 @@ Object { }, "minDestTokenAmount": "0.475", "requestId": "123", - "srcAsset": Object { + "srcAsset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", "chainId": 1151111081099710, @@ -3792,11 +3831,11 @@ Object { }, "srcChainId": 1151111081099710, "srcTokenAmount": "1000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "0.5", - "destAsset": Object { + "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", "chainId": 1, @@ -3805,13 +3844,13 @@ Object { "symbol": "ETH", }, "destChainId": 1, - "protocol": Object { + "protocol": { "displayName": "Test Protocol", "icon": "test-icon", "name": "test-protocol", }, "srcAmount": "1000000000", - "srcAsset": Object { + "srcAsset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", "chainId": 1151111081099710, @@ -3825,8 +3864,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 1151111081099710, "txHash": "signature", }, @@ -3838,20 +3877,20 @@ Object { `; exports[`BridgeStatusController submitTx: Solana bridge should throw error when snap ID is missing 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3859,6 +3898,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_test-bridge", "quoted_time_minutes": 5, @@ -3871,10 +3911,10 @@ Array [ "usd_quoted_return": 985, }, ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3883,6 +3923,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_test-bridge", "quoted_time_minutes": 5, @@ -3899,20 +3940,20 @@ Array [ `; exports[`BridgeStatusController submitTx: Solana swap should handle snap controller errors 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3920,6 +3961,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": true, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 5, @@ -3932,16 +3974,16 @@ Array [ "usd_quoted_return": 985, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -3950,10 +3992,10 @@ Array [ "snapId": "test-snap", }, ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3962,6 +4004,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": true, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 5, @@ -3978,20 +4021,20 @@ Array [ `; exports[`BridgeStatusController submitTx: Solana swap should successfully submit a transaction 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -3999,6 +4042,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": true, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 5, @@ -4011,16 +4055,16 @@ Array [ "usd_quoted_return": 985, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", @@ -4029,18 +4073,18 @@ Array [ "snapId": "test-snap", }, ], - Array [ + [ "AccountsController:getAccountByAddress", "0x123...", ], - Array [ + [ "TransactionController:getState", ], ] `; exports[`BridgeStatusController submitTx: Solana swap should successfully submit a transaction 2`] = ` -Object { +{ "approvalTxId": undefined, "chainId": "0x416edef1601be", "destinationChainId": "0x416edef1601be", @@ -4061,7 +4105,7 @@ Object { "status": "submitted", "swapTokenValue": "1", "time": 1234567890, - "txParams": Object { + "txParams": { "data": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", "from": "0x123...", }, @@ -4070,7 +4114,7 @@ Object { `; exports[`BridgeStatusController submitTx: Solana swap should successfully submit a transaction 3`] = ` -Object { +{ "account": "0x123...", "actionId": undefined, "approvalTxId": undefined, @@ -4080,18 +4124,19 @@ Object { "hasApprovalTx": false, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "signature", - "pricingData": Object { + "pricingData": { "amountSent": "1", "amountSentInUsd": "100", "quotedGasAmount": "0.05", "quotedGasInUsd": "5", "quotedReturnInUsd": "1000", }, - "quote": Object { + "quote": { "bridgeId": "test-bridge", - "bridges": Array [], - "destAsset": Object { + "bridges": [], + "destAsset": { "address": "0x...", "assetId": "eip155:1399811149/slip44:501", "chainId": 1151111081099710, @@ -4101,10 +4146,10 @@ Object { }, "destChainId": 1151111081099710, "destTokenAmount": "500000000000000000s", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "1000000", - "asset": Object { + "asset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", "chainId": 1151111081099710, @@ -4116,7 +4161,7 @@ Object { }, "minDestTokenAmount": "475000000000000000s", "requestId": "123", - "srcAsset": Object { + "srcAsset": { "address": "native", "assetId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", "chainId": 1151111081099710, @@ -4126,11 +4171,11 @@ Object { }, "srcChainId": 1151111081099710, "srcTokenAmount": "1000000000", - "steps": Array [ - Object { + "steps": [ + { "action": "bridge", "destAmount": "0.5", - "destAsset": Object { + "destAsset": { "address": "0x...", "assetId": "eip155:1/slip44:60", "chainId": 1, @@ -4139,13 +4184,13 @@ Object { "symbol": "ETH", }, "destChainId": 1, - "protocol": Object { + "protocol": { "displayName": "Test Protocol", "icon": "test-icon", "name": "test-protocol", }, "srcAmount": "1000000000", - "srcAsset": Object { + "srcAsset": { "address": "native", "assetId": "eip155:1399811149/slip44:501", "chainId": 1151111081099710, @@ -4159,8 +4204,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 1151111081099710, "txHash": "signature", }, @@ -4172,13 +4217,13 @@ Object { `; exports[`BridgeStatusController submitTx: Solana swap should throw error when account is missing 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], @@ -4186,20 +4231,20 @@ Array [ `; exports[`BridgeStatusController submitTx: Solana swap should throw error when snap ID is missing 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "SOLaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -4207,6 +4252,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 5, @@ -4219,10 +4265,10 @@ Array [ "usd_quoted_return": 985, }, ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", @@ -4231,6 +4277,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 5, @@ -4247,20 +4294,20 @@ Array [ `; exports[`BridgeStatusController submitTx: Tron swap with approval should handle approval transaction errors 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "TRXaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "tron:728126428", "chain_id_source": "tron:728126428", @@ -4268,6 +4315,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 0.5, @@ -4280,18 +4328,18 @@ Array [ "usd_quoted_return": 499.99, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "tron-account-1", - "options": Object { + "options": { "type": undefined, "visible": undefined, }, @@ -4302,10 +4350,10 @@ Array [ "snapId": "npm:@metamask/tron-snap", }, ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "tron:728126428", "chain_id_source": "tron:728126428", @@ -4314,6 +4362,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 0.5, @@ -4330,20 +4379,20 @@ Array [ `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "TRXaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:1", "chain_id_source": "tron:728126428", @@ -4351,6 +4400,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 0.5, @@ -4363,18 +4413,18 @@ Array [ "usd_quoted_return": 499.99, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "tron-account-1", - "options": Object { + "options": { "type": undefined, "visible": undefined, }, @@ -4385,18 +4435,18 @@ Array [ "snapId": "npm:@metamask/tron-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "tron-account-1", - "options": Object { + "options": { "type": undefined, "visible": undefined, }, @@ -4411,7 +4461,7 @@ Array [ `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 2`] = ` -Object { +{ "approvalTxId": undefined, "chainId": "0x2b6653dc", "destinationChainId": "0x1", @@ -4432,7 +4482,7 @@ Object { "status": "submitted", "swapTokenValue": "1", "time": 1234567890, - "txParams": Object { + "txParams": { "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", "from": "TRX123...", }, @@ -4441,13 +4491,13 @@ Object { `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 3`] = ` -Object { +{ "bridgeTxMetaId": "bridge-signature", } `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron bridge with approval transaction 4`] = ` -Object { +{ "account": "TRX123...", "actionId": undefined, "approvalTxId": "approval-signature", @@ -4457,18 +4507,19 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "bridge-signature", - "pricingData": Object { + "pricingData": { "amountSent": "1", "amountSentInUsd": "1", "quotedGasAmount": "0.005", "quotedGasInUsd": "0.005", "quotedReturnInUsd": "500", }, - "quote": Object { + "quote": { "bridgeId": "test-bridge", - "bridges": Array [], - "destAsset": Object { + "bridges": [], + "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4478,10 +4529,10 @@ Object { }, "destChainId": 1, "destTokenAmount": "500000000", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "10000", - "asset": Object { + "asset": { "address": "native", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4493,7 +4544,7 @@ Object { }, "minDestTokenAmount": "475000000", "requestId": "123", - "srcAsset": Object { + "srcAsset": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4503,11 +4554,11 @@ Object { }, "srcChainId": 728126428, "srcTokenAmount": "1000000", - "steps": Array [ - Object { + "steps": [ + { "action": "swap", "destAmount": "500000000", - "destAsset": Object { + "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4516,13 +4567,13 @@ Object { "symbol": "TRX", }, "destChainId": 728126428, - "protocol": Object { + "protocol": { "displayName": "Test Protocol", "icon": "test-icon", "name": "test-protocol", }, "srcAmount": "1000000", - "srcAsset": Object { + "srcAsset": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4536,8 +4587,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 728126428, "txHash": "bridge-signature", }, @@ -4549,20 +4600,20 @@ Object { `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron swap with approval transaction 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:stopPollingForQuotes", "Transaction submitted", undefined, ], - Array [ + [ "AccountsController:getAccountByAddress", "TRXaccountAddress", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Submitted", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "tron:728126428", "chain_id_source": "tron:728126428", @@ -4570,6 +4621,7 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "test-bridge_undefined", "quoted_time_minutes": 0.5, @@ -4582,18 +4634,18 @@ Array [ "usd_quoted_return": 499.99, }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "tron-account-1", - "options": Object { + "options": { "type": undefined, "visible": undefined, }, @@ -4604,18 +4656,18 @@ Array [ "snapId": "npm:@metamask/tron-snap", }, ], - Array [ + [ "SnapController:handleRequest", - Object { + { "handler": "onClientRequest", "origin": "metamask", - "request": Object { + "request": { "id": "test-uuid-1234", "jsonrpc": "2.0", "method": "signAndSendTransaction", - "params": Object { + "params": { "accountId": "tron-account-1", - "options": Object { + "options": { "type": undefined, "visible": undefined, }, @@ -4630,7 +4682,7 @@ Array [ `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron swap with approval transaction 2`] = ` -Object { +{ "approvalTxId": undefined, "chainId": "0x2b6653dc", "destinationChainId": "0x2b6653dc", @@ -4651,7 +4703,7 @@ Object { "status": "submitted", "swapTokenValue": "1", "time": 1234567890, - "txParams": Object { + "txParams": { "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", "from": "TRX123...", }, @@ -4660,7 +4712,7 @@ Object { `; exports[`BridgeStatusController submitTx: Tron swap with approval should successfully submit a Tron swap with approval transaction 3`] = ` -Object { +{ "account": "TRX123...", "actionId": undefined, "approvalTxId": "approval-signature", @@ -4670,18 +4722,19 @@ Object { "hasApprovalTx": true, "initialDestAssetBalance": undefined, "isStxEnabled": false, + "location": "Main View", "originalTransactionId": "swap-signature", - "pricingData": Object { + "pricingData": { "amountSent": "1", "amountSentInUsd": "1", "quotedGasAmount": "0.005", "quotedGasInUsd": "0.005", "quotedReturnInUsd": "500", }, - "quote": Object { + "quote": { "bridgeId": "test-bridge", - "bridges": Array [], - "destAsset": Object { + "bridges": [], + "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4691,10 +4744,10 @@ Object { }, "destChainId": 728126428, "destTokenAmount": "500000000", - "feeData": Object { - "metabridge": Object { + "feeData": { + "metabridge": { "amount": "10000", - "asset": Object { + "asset": { "address": "native", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4706,7 +4759,7 @@ Object { }, "minDestTokenAmount": "475000000", "requestId": "123", - "srcAsset": Object { + "srcAsset": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4716,11 +4769,11 @@ Object { }, "srcChainId": 728126428, "srcTokenAmount": "1000000", - "steps": Array [ - Object { + "steps": [ + { "action": "swap", "destAmount": "500000000", - "destAsset": Object { + "destAsset": { "address": "native", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4729,13 +4782,13 @@ Object { "symbol": "TRX", }, "destChainId": 728126428, - "protocol": Object { + "protocol": { "displayName": "Test Protocol", "icon": "test-icon", "name": "test-protocol", }, "srcAmount": "1000000", - "srcAsset": Object { + "srcAsset": { "address": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", "assetId": "tron:728126428/slip44:195", "chainId": 728126428, @@ -4749,8 +4802,8 @@ Object { }, "slippagePercentage": 0, "startTime": 1234567890, - "status": Object { - "srcChain": Object { + "status": { + "srcChain": { "chainId": 728126428, "txHash": "swap-signature", }, @@ -4761,21 +4814,22 @@ Object { } `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not start polling for bridge tx if tx is not in txHistory 1`] = `Array []`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not start polling for bridge tx if tx is not in txHistory 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not track completed event for other transaction types 1`] = `Array []`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should not track completed event for other transaction types 1`] = `[]`; exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for bridge tx if status response is invalid 1`] = ` -Array [ +[ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Status Failed Validation", - Object { + { "action_type": "swapbridge-v1", "chain_id_destination": "eip155:10", "chain_id_source": "eip155:42161", - "failures": Array [ - "across|status", + "failures": [ + "across|unknown", ], + "location": "Main View", "refresh_count": 0, "token_address_destination": "eip155:10/slip44:60", "token_address_source": "eip155:42161/slip44:60", @@ -4784,25 +4838,25 @@ Array [ `; exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for bridge tx if status response is invalid 2`] = ` -Array [ - Array [ +[ + [ "Failed to fetch bridge tx status", - [Error: Bridge status validation failed: across|unknown], + [Error: Bridge status validation failed: across|status], ], - Array [ + [ "Failed to fetch bridge tx status", - [Error: Bridge status validation failed: across|status], + [Error: Bridge status validation failed: across|unknown], ], ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for completed bridge tx with featureId 1`] = ` -Object { +exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for completed bridge tx with featureId 2`] = ` +{ "bridge": "across", - "destChain": Object { + "destChain": { "amount": "990654755978611", "chainId": 10, - "token": Object { + "token": { "address": "0x0000000000000000000000000000000000000000", "chainId": 10, "coinKey": "ETH", @@ -4816,10 +4870,10 @@ Object { "txHash": "0xdestTxHash1", }, "isExpectedToken": true, - "srcChain": Object { + "srcChain": { "amount": "991250000000000", "chainId": 42161, - "token": Object { + "token": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -4837,17 +4891,17 @@ Object { } `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for failed bridge tx with featureId 1`] = ` -Object { +exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should start polling for failed bridge tx with featureId 2`] = ` +{ "bridge": "debridge", - "destChain": Object { + "destChain": { "chainId": 10, - "token": Object {}, + "token": {}, }, - "srcChain": Object { + "srcChain": { "amount": "991250000000000", "chainId": 42161, - "token": Object { + "token": { "address": "0x0000000000000000000000000000000000000000", "assetId": "eip155:42161/slip44:60", "chainId": 42161, @@ -4865,18 +4919,18 @@ Object { `; exports[`BridgeStatusController subscription handlers TransactionController:transactionConfirmed should track completed event for swap transaction 1`] = ` -Array [ - Array [ +[ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Completed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "allowance_reset_transaction": undefined, @@ -4888,12 +4942,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": false, @@ -4913,10 +4968,10 @@ Array [ `; exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should find history by actionId when txMeta.id not in history (pre-submission failure) 1`] = ` -Array [ +[ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "allowance_reset_transaction": undefined, @@ -4929,12 +4984,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": false, @@ -4952,17 +5008,17 @@ Array [ ] `; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for approved status 1`] = `Array []`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for approved status 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for other transaction types 1`] = `Array []`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for other transaction types 1`] = `[]`; -exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for signed status 1`] = `Array []`; +exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should not track failed event for signed status 1`] = `[]`; exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction 1`] = ` -Array [ +[ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "allowance_reset_transaction": undefined, @@ -4975,12 +5031,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": false, @@ -4999,10 +5056,10 @@ Array [ `; exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction if approval is dropped 1`] = ` -Array [ +[ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "chain_id_destination": "eip155:42161", @@ -5012,12 +5069,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "source_transaction": "FAILED", "stx_enabled": false, "swap_type": "crosschain", @@ -5027,7 +5085,7 @@ Array [ "token_symbol_source": "", "usd_actual_gas": 0, "usd_actual_return": 0, - "usd_amount_source": 100, + "usd_amount_source": 0, "usd_quoted_gas": 0, "usd_quoted_return": 0, }, @@ -5035,11 +5093,11 @@ Array [ `; exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for bridge transaction if not in txHistory 1`] = ` -Array [ - Array [ +[ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "chain_id_destination": "eip155:42161", @@ -5049,12 +5107,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "source_transaction": "FAILED", "stx_enabled": false, "swap_type": "crosschain", @@ -5064,7 +5123,7 @@ Array [ "token_symbol_source": "", "usd_actual_gas": 0, "usd_actual_return": 0, - "usd_amount_source": 100, + "usd_amount_source": 0, "usd_quoted_gas": 0, "usd_quoted_return": 0, }, @@ -5073,18 +5132,18 @@ Array [ `; exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for swap transaction 1`] = ` -Array [ - Array [ +[ + [ "AccountsController:getAccountByAddress", "0xaccount1", ], - Array [ + [ "TransactionController:getState", ], - Array [ + [ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "allowance_reset_transaction": undefined, @@ -5097,12 +5156,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "lifi_across", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0.25, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "slippage_limit": 0, "source_transaction": "COMPLETE", "stx_enabled": false, @@ -5122,10 +5182,10 @@ Array [ `; exports[`BridgeStatusController subscription handlers TransactionController:transactionFailed should track failed event for swap transaction if approval fails 1`] = ` -Array [ +[ "BridgeController:trackUnifiedSwapBridgeEvent", "Unified SwapBridge Failed", - Object { + { "action_type": "swapbridge-v1", "actual_time_minutes": 0, "chain_id_destination": "eip155:42161", @@ -5135,12 +5195,13 @@ Array [ "gas_included": false, "gas_included_7702": false, "is_hardware_wallet": false, + "location": "Main View", "price_impact": 0, "provider": "", "quote_vs_execution_ratio": 0, "quoted_time_minutes": 0, "quoted_vs_used_gas_ratio": 0, - "security_warnings": Array [], + "security_warnings": [], "source_transaction": "FAILED", "stx_enabled": false, "swap_type": "single_chain", @@ -5150,7 +5211,7 @@ Array [ "token_symbol_source": "", "usd_actual_gas": 0, "usd_actual_return": 0, - "usd_amount_source": 100, + "usd_amount_source": 0, "usd_quoted_gas": 0, "usd_quoted_return": 0, }, @@ -5158,11 +5219,39 @@ Array [ `; exports[`BridgeStatusController wipeBridgeStatus wipes the bridge status for the given address 1`] = ` -Array [ - Array [ +[ + [ + "AuthenticationController:getBearerToken", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount1", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + ], + [ + "AuthenticationController:getBearerToken", + ], + [ + "AccountsController:getAccountByAddress", + "0xaccount2", + ], + [ + "TransactionController:getState", + ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + ], + [ "NetworkController:getState", ], - Array [ + [ "NetworkController:getNetworkClientById", "networkClientId", ], diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 62e3ad90fb4..4488b6087da 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable jest/no-restricted-matchers */ import { deriveStateFromMetadata } from '@metamask/base-controller'; import type { @@ -364,6 +365,7 @@ const MockTxHistory = { }), hasApprovalTx: false, approvalTxId: undefined, + location: undefined, }, }), getInit: ({ @@ -389,6 +391,7 @@ const MockTxHistory = { srcChainId, }), hasApprovalTx: false, + location: undefined, }, }), getPending: ({ @@ -431,6 +434,7 @@ const MockTxHistory = { completionTime: undefined, attempts: undefined, featureId, + location: undefined, }, }), getUnknown: ({ @@ -468,6 +472,7 @@ const MockTxHistory = { approvalTxId: undefined, hasApprovalTx: false, completionTime: undefined, + location: undefined, }, }), getPendingSwap: ({ @@ -505,6 +510,7 @@ const MockTxHistory = { hasApprovalTx: false, completionTime: undefined, featureId, + location: undefined, }, }), getComplete: ({ @@ -542,6 +548,7 @@ const MockTxHistory = { isStxEnabled: true, hasApprovalTx: false, attempts: undefined, + location: undefined, }, }), }; @@ -822,8 +829,8 @@ describe('BridgeStatusController', () => { bridgeStatusController.stopAllPolling(); expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ "Failed to fetch bridge tx status", [Error: Network error], ], @@ -879,32 +886,32 @@ describe('BridgeStatusController', () => { ); bridgeStatusController.stopAllPolling(); expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ + [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], - Array [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], - Array [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], - Array [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], - Array [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], - Array [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], - Array [ + [ "Failed to fetch bridge tx status", [Error: Persistent error], ], @@ -1407,6 +1414,11 @@ describe('BridgeStatusController', () => { }); describe('wipeBridgeStatus', () => { + beforeEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + it('wipes the bridge status for the given address', async () => { // Setup jest.useFakeTimers(); @@ -1440,6 +1452,8 @@ describe('BridgeStatusController', () => { return { transactions: [{ id: 'bridgeTxMetaId1', hash: '0xsrcTxHash1' }], }; + } else if (method === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; } return null; }), @@ -1475,10 +1489,11 @@ describe('BridgeStatusController', () => { }; }); - // Start polling for 0xaccount1 bridgeStatusController.startPollingForBridgeTxStatus( getMockStartPollingForBridgeTxStatusArgs(), ); + jest.advanceTimersToNextTimer(); + await flushPromises(); jest.advanceTimersByTime(10_000); expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); @@ -1491,6 +1506,8 @@ describe('BridgeStatusController', () => { }), ); jest.advanceTimersByTime(10_000); + jest.advanceTimersToNextTimer(); + await flushPromises(); expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); // Check that both accounts have a tx history entry @@ -1513,14 +1530,18 @@ describe('BridgeStatusController', () => { ); expect(txHistoryItems).toHaveLength(1); expect(txHistoryItems[0].account).toBe('0xaccount2'); - expect(messengerMock.call.mock.calls).toMatchSnapshot(); + const { calls } = messengerMock.call.mock; + expect(calls.map((call) => call.slice(0, 2))).toMatchSnapshot(); }); - it('wipes the bridge status for all networks if ignoreNetwork is true', () => { + it('wipes the bridge status for all networks if ignoreNetwork is true', async () => { // Setup jest.useFakeTimers(); const messengerMock = { call: jest.fn((method: string) => { + if (method === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } if (method === 'AccountsController:getSelectedMultichainAccount') { return { address: '0xaccount1' }; } else if ( @@ -1583,7 +1604,9 @@ describe('BridgeStatusController', () => { destChainId: 1, }), ); - jest.advanceTimersByTime(10_000); + jest.advanceTimersToNextTimer(); + jest.advanceTimersToNextTimer(); + await flushPromises(); expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); // Start polling for chainId 10 to chainId 123 @@ -1596,7 +1619,8 @@ describe('BridgeStatusController', () => { destChainId: 123, }), ); - jest.advanceTimersByTime(10_000); + jest.advanceTimersToNextTimer(); + await flushPromises(); expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); // Check we have a tx history entry for each chainId @@ -1628,7 +1652,7 @@ describe('BridgeStatusController', () => { expect(txHistoryItems).toHaveLength(0); }); - it('wipes the bridge status only for the current network if ignoreNetwork is false', () => { + it('wipes the bridge status only for the current network if ignoreNetwork is false', async () => { // Setup jest.useFakeTimers(); const messengerMock = { @@ -1653,6 +1677,9 @@ describe('BridgeStatusController', () => { transactions: [{ id: 'bridgeTxMetaId1', hash: '0xsrcTxHash1' }], }; } + if (method === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } return null; }), subscribe: mockMessengerSubscribe, @@ -1696,6 +1723,8 @@ describe('BridgeStatusController', () => { destChainId: 1, }), ); + jest.advanceTimersToNextTimer(); + await flushPromises(); jest.advanceTimersByTime(10_000); expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); @@ -1709,6 +1738,8 @@ describe('BridgeStatusController', () => { destChainId: 123, }), ); + jest.advanceTimersToNextTimer(); + await flushPromises(); jest.advanceTimersByTime(10_000); expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); @@ -3457,7 +3488,7 @@ describe('BridgeStatusController', () => { const { txParams, ...resultsToCheck } = result; expect(resultsToCheck).toMatchInlineSnapshot(` - Object { + { "batchId": "batchId1", "chainId": "0xa4b1", "hash": "0xevmTxHash", @@ -3961,6 +3992,9 @@ describe('BridgeStatusController', () => { expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(0); // Now advance timer again - polling should work since attempts are reset + // Advance in steps to allow recursive setTimeout to be set up properly with Jest 28 + jest.advanceTimersByTime(0); + await flushPromises(); jest.advanceTimersByTime(10000); await flushPromises(); @@ -4092,6 +4126,7 @@ describe('BridgeStatusController', () => { let consoleFnSpy: jest.SpyInstance; beforeEach(() => { + jest.useFakeTimers(); jest.clearAllTimers(); jest.clearAllMocks(); // eslint-disable-next-line no-empty-function @@ -4195,6 +4230,7 @@ describe('BridgeStatusController', () => { afterEach(() => { bridgeStatusController.stopAllPolling(); console.warn = consoleFn; + jest.useRealTimers(); }); describe('TransactionController:transactionFailed', () => { @@ -4474,7 +4510,6 @@ describe('BridgeStatusController', () => { }); it('should start polling for bridge tx if status response is invalid', async () => { - jest.useFakeTimers(); const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); mockFetchFn.mockClear(); @@ -4522,7 +4557,6 @@ describe('BridgeStatusController', () => { }); it('should start polling for completed bridge tx with featureId', async () => { - jest.useFakeTimers(); const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); mockFetchFn.mockClear(); @@ -4543,7 +4577,16 @@ describe('BridgeStatusController', () => { bridgeStatusController.stopAllPolling(); await flushPromises(); - expect(messengerCallSpy).not.toHaveBeenCalled(); + expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + ] + `); expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getTxStatus?bridgeId=lifi&srcTxHash=0xperpsSrcTxHash1&bridge=across&srcChainId=42161&destChainId=10&refuel=false&requestId=197c402f-cb96-4096-9f8c-54aed84ca776', { @@ -4559,7 +4602,6 @@ describe('BridgeStatusController', () => { }); it('should start polling for failed bridge tx with featureId', async () => { - jest.useFakeTimers(); const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); mockFetchFn.mockClear(); @@ -4580,7 +4622,16 @@ describe('BridgeStatusController', () => { bridgeStatusController.stopAllPolling(); await flushPromises(); - expect(messengerCallSpy).not.toHaveBeenCalled(); + expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + ] + `); expect(mockFetchFn).toHaveBeenCalledWith( 'https://bridge.api.cx.metamask.io/getTxStatus?bridgeId=lifi&srcTxHash=0xperpsSrcTxHash1&bridge=across&srcChainId=42161&destChainId=10&refuel=false&requestId=197c402f-cb96-4096-9f8c-54aed84ca776', { @@ -4654,6 +4705,51 @@ describe('BridgeStatusController', () => { expect(messengerCallSpy.mock.calls).toMatchSnapshot(); }); + + it('should not append auth token to status request when getBearerToken throws an error', async () => { + const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + + messengerCallSpy.mockImplementation(() => { + throw new Error( + 'AuthenticationController:getBearerToken not implemented', + ); + }); + mockFetchFn.mockClear(); + mockFetchFn.mockResolvedValueOnce( + MockStatusResponse.getComplete({ srcTxHash: '0xperpsSrcTxHash1' }), + ); + mockMessenger.publish('TransactionController:transactionConfirmed', { + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + type: TransactionType.bridge, + status: TransactionStatus.confirmed, + id: 'perpsBridgeTxMetaId1', + }); + + jest.advanceTimersByTime(30500); + bridgeStatusController.stopAllPolling(); + await flushPromises(); + + expect(messengerCallSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "AuthenticationController:getBearerToken", + ], + [ + "AuthenticationController:getBearerToken", + ], + ] + `); + expect(mockFetchFn).toHaveBeenCalledWith( + 'https://bridge.api.cx.metamask.io/getTxStatus?bridgeId=lifi&srcTxHash=0xperpsSrcTxHash1&bridge=across&srcChainId=42161&destChainId=10&refuel=false&requestId=197c402f-cb96-4096-9f8c-54aed84ca776', + { + headers: { 'X-Client-Id': BridgeClientId.EXTENSION }, + }, + ); + expect(consoleFnSpy).not.toHaveBeenCalled(); + }); }); }); @@ -4667,7 +4763,7 @@ describe('BridgeStatusController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -4680,8 +4776,8 @@ describe('BridgeStatusController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "txHistory": Object {}, + { + "txHistory": {}, } `); }); @@ -4696,8 +4792,8 @@ describe('BridgeStatusController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "txHistory": Object {}, + { + "txHistory": {}, } `); }); @@ -4712,8 +4808,8 @@ describe('BridgeStatusController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "txHistory": Object {}, + { + "txHistory": {}, } `); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 9ebd3e5bfce..bedf40bffb6 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -19,6 +19,7 @@ import { isEvmTxData, isHardwareWallet, MetricsActionType, + MetaMetricsSwapsEventSource, isBitcoinTrade, isTronTrade, AbortReason, @@ -519,6 +520,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { // Use actionId as key for pre-submission, or txMeta.id for post-submission @@ -771,6 +774,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { + try { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return token; + } catch (error) { + console.error('Error getting JWT token for bridge-api request', error); + return undefined; + } + }; + readonly #getSrcTxHash = (bridgeTxMetaId: string): string | undefined => { const { txHistory } = this.state; // Prefer the srcTxHash from bridgeStatusState so we don't have to l ook up in TransactionController @@ -1297,6 +1315,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, isStxEnabledOnClient: boolean, quotesReceivedContext?: RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesReceived], + location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, ): Promise> => { this.messenger.call( 'BridgeController:stopPollingForQuotes', @@ -1325,6 +1345,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; signature: string; accountAddress: string; + location?: MetaMetricsSwapsEventSource; }): Promise> => { - const { quoteResponse, signature, accountAddress } = params; + const { quoteResponse, signature, accountAddress, location } = params; this.messenger.call( 'BridgeController:stopPollingForQuotes', @@ -1601,6 +1626,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { const baseProperties = { action_type: MetricsActionType.SWAPBRIDGE_V1, + location: + eventProperties?.location ?? + (txMetaId ? this.state.txHistory?.[txMetaId]?.location : undefined) ?? + MetaMetricsSwapsEventSource.MainView, ...(eventProperties ?? {}), }; @@ -1813,7 +1845,7 @@ export class BridgeStatusController extends StaticIntervalPollingController | GetGasFeeState | AccountsControllerGetAccountByAddressAction - | RemoteFeatureFlagControllerGetStateAction; + | RemoteFeatureFlagControllerGetStateAction + | AuthenticationControllerGetBearerToken; /** * The external events available to the BridgeStatusController. diff --git a/packages/bridge-status-controller/src/utils/bridge-status.test.ts b/packages/bridge-status-controller/src/utils/bridge-status.test.ts index 43a251c8297..473c7274198 100644 --- a/packages/bridge-status-controller/src/utils/bridge-status.test.ts +++ b/packages/bridge-status-controller/src/utils/bridge-status.test.ts @@ -98,6 +98,7 @@ describe('utils', () => { const result = await fetchBridgeTxStatus( mockStatusRequest, mockClientId, + 'AUTH_TOKEN', mockFetch, BRIDGE_PROD_API_BASE_URL, ); @@ -106,7 +107,10 @@ describe('utils', () => { expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining(getBridgeStatusUrl(BRIDGE_PROD_API_BASE_URL)), { - headers: { 'X-Client-Id': mockClientId }, + headers: { + 'X-Client-Id': mockClientId, + Authorization: 'Bearer AUTH_TOKEN', + }, }, ); @@ -135,6 +139,7 @@ describe('utils', () => { const result = await fetchBridgeTxStatus( mockStatusRequest, mockClientId, + 'AUTH_TOKEN', mockFetch, BRIDGE_PROD_API_BASE_URL, ); @@ -143,7 +148,10 @@ describe('utils', () => { expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining(getBridgeStatusUrl(BRIDGE_PROD_API_BASE_URL)), { - headers: { 'X-Client-Id': mockClientId }, + headers: { + 'X-Client-Id': mockClientId, + Authorization: 'Bearer AUTH_TOKEN', + }, }, ); @@ -157,13 +165,11 @@ describe('utils', () => { // Verify response expect(result.status).toStrictEqual(mockInvalidResponse); - expect(result.validationFailures).toMatchInlineSnapshot( - ` - Array [ + expect(result.validationFailures).toMatchInlineSnapshot(` + [ "socket|status", ] - `, - ); + `); }); it('should throw error when response validation fails', async () => { @@ -178,6 +184,7 @@ describe('utils', () => { const result = await fetchBridgeTxStatus( mockStatusRequest, mockClientId, + 'AUTH_TOKEN', mockFetch, BRIDGE_PROD_API_BASE_URL, ); @@ -186,7 +193,7 @@ describe('utils', () => { expect(result.validationFailures).toMatchInlineSnapshot( ['socket|status', 'socket|srcChain'], ` - Array [ + [ "socket|status", "socket|srcChain", ] @@ -203,6 +210,7 @@ describe('utils', () => { fetchBridgeTxStatus( mockStatusRequest, mockClientId, + 'AUTH_TOKEN', mockFetch, BRIDGE_PROD_API_BASE_URL, ), diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts index fa5d0890a8b..960e321610e 100644 --- a/packages/bridge-status-controller/src/utils/bridge-status.ts +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -1,3 +1,4 @@ +import { getClientHeaders } from '@metamask/bridge-controller'; import type { Quote } from '@metamask/bridge-controller'; import { StructError } from '@metamask/superstruct'; @@ -11,11 +12,7 @@ import type { BridgeHistoryItem, } from '../types'; -export const getClientIdHeader = (clientId: string) => ({ - 'X-Client-Id': clientId, -}); - -export const getBridgeStatusUrl = (bridgeApiBaseUrl: string) => +export const getBridgeStatusUrl = (bridgeApiBaseUrl: string): string => `${bridgeApiBaseUrl}/getTxStatus`; export const getStatusRequestDto = ( @@ -42,6 +39,7 @@ export const getStatusRequestDto = ( export const fetchBridgeTxStatus = async ( statusRequest: StatusRequestWithSrcTxHash, clientId: string, + jwt: string | undefined, fetchFn: FetchFunction, bridgeApiBaseUrl: string, ): Promise<{ status: StatusResponse; validationFailures: string[] }> => { @@ -52,7 +50,7 @@ export const fetchBridgeTxStatus = async ( const url = `${getBridgeStatusUrl(bridgeApiBaseUrl)}?${params.toString()}`; const rawTxStatus: unknown = await fetchFn(url, { - headers: getClientIdHeader(clientId), + headers: getClientHeaders({ clientId, jwt }), }); const validationFailures: string[] = []; @@ -64,12 +62,11 @@ export const fetchBridgeTxStatus = async ( if (error instanceof StructError) { error.failures().forEach(({ branch, path }) => { const aggregatorId = - branch?.[0]?.quote?.bridgeId || - branch?.[0]?.quote?.bridges?.[0] || - (rawTxStatus as StatusResponse)?.bridge || - statusRequest.bridge || - statusRequest.bridgeId || - 'unknown'; + branch?.[0]?.quote?.bridgeId ?? + branch?.[0]?.quote?.bridges?.[0] ?? + (rawTxStatus as StatusResponse)?.bridge ?? + (statusRequest.bridge || statusRequest.bridgeId) ?? + ('unknown' as string); const pathString = path?.join('.') || 'unknown'; validationFailures.push([aggregatorId, pathString].join('|')); }); @@ -99,7 +96,7 @@ export const getStatusRequestWithSrcTxHash = ( export const shouldSkipFetchDueToFetchFailures = ( attempts?: BridgeHistoryItem['attempts'], -) => { +): boolean => { // If there's an attempt, it means we've failed at least once, // so we need to check if we need to wait longer due to exponential backoff if (attempts) { diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index fe38dd606a8..c2c815f92b3 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -1,7 +1,6 @@ -import { StatusTypes } from '@metamask/bridge-controller'; +import { getClientHeaders, StatusTypes } from '@metamask/bridge-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; -import { getClientIdHeader } from './bridge-status'; import { IntentOrder, IntentOrderStatus, @@ -22,12 +21,14 @@ export type IntentApi = { submitIntent( params: IntentSubmissionParams, clientId: string, + jwt: string, ): Promise; getOrderStatus( orderId: string, aggregatorId: string, srcChainId: string, clientId: string, + jwt: string, ): Promise; }; @@ -44,6 +45,7 @@ export class IntentApiImpl implements IntentApi { async submitIntent( params: IntentSubmissionParams, clientId: string, + jwt: string | undefined, ): Promise { const endpoint = `${this.#baseUrl}/submitOrder`; try { @@ -51,7 +53,7 @@ export class IntentApiImpl implements IntentApi { method: 'POST', headers: { 'Content-Type': 'application/json', - ...getClientIdHeader(clientId), + ...getClientHeaders({ clientId, jwt }), }, body: JSON.stringify(params), }); @@ -72,12 +74,13 @@ export class IntentApiImpl implements IntentApi { aggregatorId: string, srcChainId: string, clientId: string, + jwt: string | undefined, ): Promise { const endpoint = `${this.#baseUrl}/getOrderStatus?orderId=${orderId}&aggregatorId=${encodeURIComponent(aggregatorId)}&srcChainId=${srcChainId}`; try { const response = await this.#fetchFn(endpoint, { method: 'GET', - headers: getClientIdHeader(clientId), + headers: getClientHeaders({ clientId, jwt }), }); if (!validateIntentOrderResponse(response)) { throw new Error('Invalid getOrderStatus response'); diff --git a/packages/bridge-status-controller/src/utils/metrics.test.ts b/packages/bridge-status-controller/src/utils/metrics.test.ts index 6f7819fccf6..2d17838369e 100644 --- a/packages/bridge-status-controller/src/utils/metrics.test.ts +++ b/packages/bridge-status-controller/src/utils/metrics.test.ts @@ -249,7 +249,7 @@ describe('metrics utils', () => { } as never, ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0.016666666666666666, "quote_vs_execution_ratio": 1.1251337476231986, "quoted_vs_used_gas_ratio": 2.8325818363563227, @@ -292,7 +292,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 1, "quote_vs_execution_ratio": 0.9801662314040546, "quoted_vs_used_gas_ratio": 2.0851258834973363, @@ -335,7 +335,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 1, "quote_vs_execution_ratio": 0.9801662314040546, "quoted_vs_used_gas_ratio": 2.0851258834973363, @@ -397,7 +397,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0, "quote_vs_execution_ratio": 0.9799999911934969, "quoted_vs_used_gas_ratio": 2.6099633492283485, @@ -458,7 +458,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0, "quote_vs_execution_ratio": 0, "quoted_vs_used_gas_ratio": 2.6099633492283485, @@ -498,7 +498,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0, "quote_vs_execution_ratio": 1, "quoted_vs_used_gas_ratio": 2.0851258834973363, @@ -561,7 +561,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0, "quote_vs_execution_ratio": 0, "quoted_vs_used_gas_ratio": 2.6099633492283485, @@ -604,7 +604,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0, "quote_vs_execution_ratio": 0, "quoted_vs_used_gas_ratio": 2.6099633492283485, @@ -640,7 +640,7 @@ describe('metrics utils', () => { ); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0, "quote_vs_execution_ratio": 0, "quoted_vs_used_gas_ratio": 0, @@ -662,7 +662,7 @@ describe('metrics utils', () => { }, }); expect(result).toMatchInlineSnapshot(` - Object { + { "actual_time_minutes": 0.016666666666666666, "quote_vs_execution_ratio": 1.1251337476231986, "quoted_vs_used_gas_ratio": 0, @@ -782,7 +782,7 @@ describe('metrics utils', () => { it('should return correct trade data', () => { const result = getTradeDataFromHistory(mockHistoryItem); expect(result).toMatchInlineSnapshot(` - Object { + { "gas_included": false, "gas_included_7702": false, "provider": "across_across", @@ -991,7 +991,7 @@ describe('metrics utils', () => { chain_id_destination: 'eip155:1', token_symbol_source: 'ETH', token_symbol_destination: 'USDC', - usd_amount_source: 100, + usd_amount_source: 0, source_transaction: 'COMPLETE', stx_enabled: false, token_address_source: 'eip155:1/slip44:60', diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 34dc3e90ec8..86282def1d6 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -1,3 +1,6 @@ +/* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import type { AccountsControllerState } from '@metamask/accounts-controller'; import { StatusTypes, @@ -11,6 +14,7 @@ import { formatAddressToAssetId, MetricsActionType, MetricsSwapType, + MetaMetricsSwapsEventSource, } from '@metamask/bridge-controller'; import type { QuoteFetchData, @@ -172,12 +176,14 @@ export const getPriceImpactFromQuote = ( * @param quoteResponse - The quote response * @param isStxEnabledOnClient - Whether smart transactions are enabled on the client, for example the getSmartTransactionsEnabled selector value from the extension * @param isHardwareAccount - whether the tx is submitted using a hardware wallet + * @param location - The entry point from which the user initiated the swap or bridge (e.g. Main View, Token View, Trending Explore) * @returns The properties for the pre-confirmation event */ export const getPreConfirmationPropertiesFromQuote = ( quoteResponse: QuoteResponse & Partial, isStxEnabledOnClient: boolean, isHardwareAccount: boolean, + location: MetaMetricsSwapsEventSource = MetaMetricsSwapsEventSource.MainView, ) => { const { quote } = quoteResponse; return { @@ -196,6 +202,7 @@ export const getPreConfirmationPropertiesFromQuote = ( stx_enabled: isStxEnabledOnClient, action_type: MetricsActionType.SWAPBRIDGE_V1, custom_slippage: false, // TODO detect whether the user changed the default slippage + location, }; }; @@ -253,7 +260,7 @@ export const getEVMTxPropertiesFromTransactionMeta = ( chain_id_destination: formatChainIdToCaip(transactionMeta.chainId), token_symbol_source: transactionMeta.sourceTokenSymbol ?? '', token_symbol_destination: transactionMeta.destinationTokenSymbol ?? '', - usd_amount_source: 100, + usd_amount_source: 0, stx_enabled: false, token_address_source: formatAddressToAssetId( diff --git a/packages/bridge-status-controller/tsconfig.json b/packages/bridge-status-controller/tsconfig.json index e41150bdaf3..827fb85f074 100644 --- a/packages/bridge-status-controller/tsconfig.json +++ b/packages/bridge-status-controller/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../network-controller" }, { "path": "../polling-controller" }, { "path": "../transaction-controller" }, + { "path": "../profile-sync-controller" }, { "path": "../gas-fee-controller" } ], "include": ["../../types", "./src"] diff --git a/packages/build-utils/package.json b/packages/build-utils/package.json index 0389d0d1c3a..2d4e745183b 100644 --- a/packages/build-utils/package.json +++ b/packages/build-utils/package.json @@ -54,11 +54,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/chain-agnostic-permission/package.json b/packages/chain-agnostic-permission/package.json index c813eded3a3..599299738af 100644 --- a/packages/chain-agnostic-permission/package.json +++ b/packages/chain-agnostic-permission/package.json @@ -59,11 +59,11 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-internal-api": "^10.0.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/chain-agnostic-permission/src/index.test.ts b/packages/chain-agnostic-permission/src/index.test.ts index 5e4cf167c7d..fcc3f16a4a5 100644 --- a/packages/chain-agnostic-permission/src/index.test.ts +++ b/packages/chain-agnostic-permission/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/chain-agnostic-permission', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "getEthAccounts", "setEthAccounts", "setNonSCACaipAccountIdsInCaip25CaveatValue", diff --git a/packages/chain-agnostic-permission/src/scope/constants.test.ts b/packages/chain-agnostic-permission/src/scope/constants.test.ts index 8177a3154f4..9b3dafdc915 100644 --- a/packages/chain-agnostic-permission/src/scope/constants.test.ts +++ b/packages/chain-agnostic-permission/src/scope/constants.test.ts @@ -7,9 +7,9 @@ import { describe('KnownRpcMethods', () => { it('should match the snapshot', () => { expect(KnownRpcMethods).toMatchInlineSnapshot(` - Object { - "bip122": Array [], - "eip155": Array [ + { + "bip122": [], + "eip155": [ "personal_sign", "eth_signTypedData_v4", "wallet_watchAsset", @@ -53,8 +53,8 @@ describe('KnownRpcMethods', () => { "eth_syncing", "eth_uninstallFilter", ], - "solana": Array [], - "tron": Array [], + "solana": [], + "tron": [], } `); }); @@ -63,7 +63,7 @@ describe('KnownRpcMethods', () => { describe('KnownSessionProperties', () => { it('should match the snapshot', () => { expect(KnownSessionProperties).toMatchInlineSnapshot(` - Object { + { "Bip122AccountChangedNotifications": "bip122_accountChanged_notifications", "SolanaAccountChangedNotifications": "solana_accountChanged_notifications", "TronAccountChangedNotifications": "tron_accountChanged_notifications", diff --git a/packages/claims-controller/package.json b/packages/claims-controller/package.json index 084fd76ca6f..96bdb2ba696 100644 --- a/packages/claims-controller/package.json +++ b/packages/claims-controller/package.json @@ -58,11 +58,11 @@ "@metamask/keyring-controller": "^25.1.0", "@metamask/profile-sync-controller": "^27.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/client-controller/CHANGELOG.md b/packages/client-controller/CHANGELOG.md new file mode 100644 index 00000000000..f9fdba129a0 --- /dev/null +++ b/packages/client-controller/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] + +### Added + +- Initial release of `@metamask/client-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) + - `ClientController` for managing client (UI) open/closed state + - `ClientController:setUiOpen` messenger action for platform code to call + - `ClientController:stateChange` event for controllers to subscribe to lifecycle changes + - `isUiOpen` state property (not persisted - always starts as `false`) + - `clientControllerSelectors.selectIsUiOpen` selector for derived state access + - Full TypeScript support with exported types + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/client-controller@1.0.0...HEAD +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/client-controller@1.0.0 diff --git a/packages/client-controller/LICENSE b/packages/client-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/client-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/client-controller/README.md b/packages/client-controller/README.md new file mode 100644 index 00000000000..a9912c2a1c6 --- /dev/null +++ b/packages/client-controller/README.md @@ -0,0 +1,180 @@ +# `@metamask/client-controller` + +Client-level state for MetaMask (e.g. whether a UI window is open). Provides a centralized way for controllers to respond to application lifecycle changes. + +## Installation + +```bash +yarn add @metamask/client-controller +``` + +or + +```bash +npm install @metamask/client-controller +``` + +## Usage + +### Basic Setup + +```typescript +import { Messenger } from '@metamask/messenger'; +import { + ClientController, + ClientControllerActions, + ClientControllerEvents, +} from '@metamask/client-controller'; + +const rootMessenger = new Messenger< + 'Root', + ClientControllerActions, + ClientControllerEvents +>({ namespace: 'Root' }); + +const controllerMessenger = new Messenger({ + namespace: 'ClientController', + parent: rootMessenger, +}); + +const clientController = new ClientController({ + messenger: controllerMessenger, +}); +``` + +### Platform Integration + +Platform code calls `ClientController:setUiOpen` when the UI is opened or +closed: + +```text +onUiOpened() { + controllerMessenger.call('ClientController:setUiOpen', true); +} + +onUiClosed() { + controllerMessenger.call('ClientController:setUiOpen', false); +} +``` + +### Consumer controller and using with other lifecycle state (e.g. Keyring unlock/lock) + +Use `ClientController:stateChange` only for behavior that **must** run when the +UI is open or closed (e.g., pausing/resuming a critical background task). **Use +the selector** when subscribing so the handler receives a single derived value +(e.g. `isUiOpen`), and **prefer pause/resume** over stop/start for polling. + +UI open/close alone is usually not enough to decide when to start or stop work. +Combine `ClientController:stateChange` with other lifecycle events, such as +**KeyringController:unlock** / **KeyringController:lock** (or any controller that +expresses "ready for background work"). Only start subscriptions, polling, or +network requests when **both** the UI is open and the keyring (or equivalent) is +unlocked; stop or pause when the UI closes **or** the keyring locks. + +#### Important: Usage guidelines and warnings + +**Do not subscribe to updates for all kinds of data as soon as the client +opens.** When MetaMask opens, the current screen may not need every type of +data. Starting subscriptions, polling, or network requests for everything when +`isUiOpen` becomes true can lead to unnecessary network traffic and battery +use, requests before onboarding is complete (a recurring source of issues), and +poor performance as more features are added. + +**Use this controller responsibly:** + +- Start only the subscriptions, polling, or requests that are **needed for the + current screen or flow** +- Do **not** start network-dependent or heavy behavior solely because + `ClientController:stateChange` reported `isUiOpen: true` +- Consider **deferring** non-critical updates until the user has completed + onboarding or reached a screen that needs that data +- Prefer starting and stopping per feature or per screen (e.g., when a + component mounts that needs the data) rather than globally when the client + opens +- **Combine with Keyring unlock/lock:** Only start work when it is appropriate + for both UI open state and wallet state (e.g. client open **and** keyring + unlocked) +- **Prefer pause/resume over stop/start for polling** so you can resume without + full re-initialization. Use the selector when subscribing (see example + below). + +```typescript +import { clientControllerSelectors } from '@metamask/client-controller'; + +class SomeDataController extends BaseController { + #uiOpen = false; + #keyringUnlocked = false; + + constructor({ messenger }) { + super({ messenger, ... }); + + messenger.subscribe( + 'ClientController:stateChange', + (isUiOpen) => { + this.#uiOpen = isUiOpen; + this.updateActive(); + }, + clientControllerSelectors.selectIsUiOpen, + ); + + messenger.subscribe('KeyringController:unlock', () => { + this.#keyringUnlocked = true; + this.updateActive(); + }); + + messenger.subscribe('KeyringController:lock', () => { + this.#keyringUnlocked = false; + this.updateActive(); + }); + } + + updateActive() { + const shouldRun = this.#uiOpen && this.#keyringUnlocked; + if (shouldRun) { + this.resume(); + } else { + this.pause(); + } + } +} +``` + +Note: `stateChange` emits `[state, patches]`; the selector receives the full +payload and returns the value passed to the handler (here, `isUiOpen`). + +## API Reference + +### State + +| Property | Type | Description | +| ---------- | --------- | ------------------------------------------ | +| `isUiOpen` | `boolean` | Whether the client (UI) is currently open. | + +State is not persisted. It always starts as `false`. + +### Actions + +| Action | Parameters | Description | +| ---------------------------- | --------------- | ---------------------------- | +| `ClientController:getState` | none | Returns current state. | +| `ClientController:setUiOpen` | `open: boolean` | Sets whether the UI is open. | + +### Events + +| Event | Payload | Description | +| ------------------------------ | ------------------ | ---------------------------- | +| `ClientController:stateChange` | `[state, patches]` | Standard state change event. | + +### Selectors + +```typescript +import { clientControllerSelectors } from '@metamask/client-controller'; + +const state = messenger.call('ClientController:getState'); +const isOpen = clientControllerSelectors.selectIsUiOpen(state); +``` + +## 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/client-controller/jest.config.js b/packages/client-controller/jest.config.js new file mode 100644 index 00000000000..9efbc1e7d1f --- /dev/null +++ b/packages/client-controller/jest.config.js @@ -0,0 +1,24 @@ +/* + * 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, { + displayName, + coveragePathIgnorePatterns: [], + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/client-controller/package.json b/packages/client-controller/package.json new file mode 100644 index 00000000000..a93784be46d --- /dev/null +++ b/packages/client-controller/package.json @@ -0,0 +1,73 @@ +{ + "name": "@metamask/client-controller", + "version": "1.0.0", + "description": "Client-level state for MetaMask (e.g. whether a UI window is open)", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/client-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/client-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/client-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": "^9.0.0", + "@metamask/messenger": "^0.3.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/client-controller/src/ClientController-method-action-types.ts b/packages/client-controller/src/ClientController-method-action-types.ts new file mode 100644 index 00000000000..395b224ee67 --- /dev/null +++ b/packages/client-controller/src/ClientController-method-action-types.ts @@ -0,0 +1,25 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ClientController } from './ClientController'; + +/** + * Updates state with whether the MetaMask UI is open. + * + * This method should be called when the user has opened the first window or + * screen containing the MetaMask UI, or closed the last window or screen + * containing the MetaMask UI. + * + * @param open - Whether the MetaMask UI is open. + */ +export type ClientControllerSetUiOpenAction = { + type: `ClientController:setUiOpen`; + handler: ClientController['setUiOpen']; +}; + +/** + * Union of all ClientController action types. + */ +export type ClientControllerMethodActions = ClientControllerSetUiOpenAction; diff --git a/packages/client-controller/src/ClientController.test.ts b/packages/client-controller/src/ClientController.test.ts new file mode 100644 index 00000000000..c7c7b7cf412 --- /dev/null +++ b/packages/client-controller/src/ClientController.test.ts @@ -0,0 +1,189 @@ +import { Messenger } from '@metamask/messenger'; + +import type { + ClientControllerActions, + ClientControllerEvents, + ClientControllerMessenger, +} from './ClientController'; +import { + ClientController, + controllerName, + getDefaultClientControllerState, +} from './ClientController'; +import { clientControllerSelectors } from './selectors'; + +describe('ClientController', () => { + type RootMessenger = Messenger< + 'Root', + ClientControllerActions, + ClientControllerEvents + >; + + /** + * Constructs the root messenger. + * + * @returns The root messenger. + */ + function getRootMessenger(): RootMessenger { + return new Messenger< + 'Root', + ClientControllerActions, + ClientControllerEvents + >({ namespace: 'Root' }); + } + + /** + * Constructs the messenger for the ClientController. + * + * @param rootMessenger - The root messenger. + * @returns The controller-specific messenger. + */ + function getMessenger( + rootMessenger: RootMessenger, + ): ClientControllerMessenger { + return new Messenger< + typeof controllerName, + ClientControllerActions, + ClientControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + } + + type WithControllerCallback = (payload: { + controller: ClientController; + rootMessenger: RootMessenger; + messenger: ClientControllerMessenger; + }) => Promise | ReturnValue; + + type WithControllerOptions = { + options: Partial[0]>; + }; + + /** + * Wraps tests for the controller by creating the controller and messengers, + * then calling the test function with them. + * + * @param args - Either a callback, or an options bag + a callback. The + * options bag contains arguments for the controller constructor. The + * callback is called with the new controller, root messenger, and + * controller messenger. + * @returns The return value of the callback. + */ + async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] + ): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const controller = new ClientController({ + messenger, + ...options, + }); + return await testFunction({ controller, rootMessenger, messenger }); + } + + describe('constructor', () => { + it('initializes with default state (client closed)', async () => { + await withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + { + "isUiOpen": false, + } + `); + }); + }); + + it('allows initializing with partial state', async () => { + const givenState = { isUiOpen: true }; + await withController( + { options: { state: givenState } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(givenState); + }, + ); + }); + + it('merges partial state with defaults', async () => { + await withController({ options: { state: {} } }, ({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + { + "isUiOpen": false, + } + `); + }); + }); + }); + + describe('setUiOpen', () => { + it('updates isUiOpen in state to the given value', async () => { + await withController(({ controller }) => { + controller.setUiOpen(true); + + expect(controller.state).toMatchInlineSnapshot(` + { + "isUiOpen": true, + } + `); + + controller.setUiOpen(false); + + expect(controller.state).toMatchInlineSnapshot(` + { + "isUiOpen": false, + } + `); + }); + }); + }); + + describe('messenger actions', () => { + it('allows setting client open via messenger action', async () => { + await withController(({ controller, messenger }) => { + messenger.call(`${controllerName}:setUiOpen`, true); + expect(controller.state).toStrictEqual({ isUiOpen: true }); + }); + }); + + it('allows setting client closed via messenger action', async () => { + await withController(({ controller, messenger }) => { + controller.setUiOpen(true); + messenger.call(`${controllerName}:setUiOpen`, false); + expect(controller.state).toStrictEqual({ isUiOpen: false }); + }); + }); + }); + + describe('getDefaultClientControllerState', () => { + it('returns default state with client closed', () => { + const defaultState = getDefaultClientControllerState(); + + expect(defaultState.isUiOpen).toBe(false); + }); + }); + + describe('selectors', () => { + describe('selectIsUiOpen', () => { + it('returns true when client is open', () => { + expect( + clientControllerSelectors.selectIsUiOpen({ + isUiOpen: true, + }), + ).toBe(true); + }); + + it('returns false when client is closed', () => { + expect( + clientControllerSelectors.selectIsUiOpen({ + isUiOpen: false, + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/packages/client-controller/src/ClientController.ts b/packages/client-controller/src/ClientController.ts new file mode 100644 index 00000000000..78553c1a990 --- /dev/null +++ b/packages/client-controller/src/ClientController.ts @@ -0,0 +1,212 @@ +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { ClientControllerMethodActions } from './ClientController-method-action-types'; + +// === GENERAL === + +/** + * The name of the {@link ClientController}. + */ +export const controllerName = 'ClientController'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link ClientController}. + */ +export type ClientControllerState = { + /** + * Whether the user has opened at least one window or screen + * containing the MetaMask UI. These windows or screens may or + * may not be in an inactive state. + */ + isUiOpen: boolean; +}; + +/** + * Constructs the default {@link ClientController} state. + * + * @returns The default {@link ClientController} state. + */ +export function getDefaultClientControllerState(): ClientControllerState { + return { + isUiOpen: false, + }; +} + +/** + * The metadata for each property in {@link ClientControllerState}. + */ +const controllerMetadata = { + isUiOpen: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['setUiOpen'] as const; + +/** + * Retrieves the state of the {@link ClientController}. + */ +export type ClientControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ClientControllerState +>; + +/** + * Actions that {@link ClientController} exposes. + */ +export type ClientControllerActions = + | ClientControllerGetStateAction + | ClientControllerMethodActions; + +/** + * Actions from other messengers that {@link ClientController} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link ClientController} changes. + */ +export type ClientControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ClientControllerState +>; + +/** + * Events that {@link ClientController} exposes. + */ +export type ClientControllerEvents = ClientControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ClientController} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger for {@link ClientController}. + */ +export type ClientControllerMessenger = Messenger< + typeof controllerName, + ClientControllerActions | AllowedActions, + ClientControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * The options for constructing a {@link ClientController}. + */ +export type ClientControllerOptions = { + /** + * The messenger suited for this controller. + */ + messenger: ClientControllerMessenger; + /** + * The initial state to set on this controller. + */ + state?: Partial; +}; + +/** + * `ClientController` manages the application lifecycle state. + * + * This controller tracks whether the MetaMask UI is open and publishes state + * change events that other controllers can subscribe to for adjusting their behavior. + * + * **Use cases:** + * - Polling controllers can pause when the UI closes, resume when it opens + * - WebSocket connections can disconnect when closed, reconnect when opened + * - Real-time subscriptions can pause when not visible + * + * **Platform Integration:** + * Platform code should call `ClientController:setUiOpen` via messenger. + * + * @example + * ```typescript + * // In MetamaskController or platform code + * onUiOpened() { + * // ... + * this.controllerMessenger.call('ClientController:setUiOpen', true); + * } + * + * onUiClosed() { + * // ... + * this.controllerMessenger.call('ClientController:setUiOpen', false); + * } + * + * // Consumer controller subscribing to state changes + * class MyController extends BaseController { + * constructor({ messenger }) { + * super({ messenger, ... }); + * + * messenger.subscribe( + * 'ClientController:stateChange', + * (isClientOpen) => { + * if (isClientOpen) { + * this.resumePolling(); + * } else { + * this.pausePolling(); + * } + * }, + * clientControllerSelectors.selectIsUiOpen, + * ); + * } + * } + * ``` + */ +export class ClientController extends BaseController< + typeof controllerName, + ClientControllerState, + ClientControllerMessenger +> { + /** + * Constructs a new {@link ClientController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - The initial state to set on this controller. + */ + constructor({ messenger, state = {} }: ClientControllerOptions) { + super({ + messenger, + metadata: controllerMetadata, + name: controllerName, + state: { + ...getDefaultClientControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Updates state with whether the MetaMask UI is open. + * + * This method should be called when the user has opened the first window or + * screen containing the MetaMask UI, or closed the last window or screen + * containing the MetaMask UI. + * + * @param open - Whether the MetaMask UI is open. + */ + setUiOpen(open: boolean): void { + this.update((state) => { + state.isUiOpen = open; + }); + } +} diff --git a/packages/client-controller/src/index.ts b/packages/client-controller/src/index.ts new file mode 100644 index 00000000000..8695331b716 --- /dev/null +++ b/packages/client-controller/src/index.ts @@ -0,0 +1,16 @@ +export { + ClientController, + getDefaultClientControllerState, +} from './ClientController'; +export { clientControllerSelectors } from './selectors'; + +export type { + ClientControllerState, + ClientControllerOptions, + ClientControllerGetStateAction, + ClientControllerActions, + ClientControllerStateChangeEvent, + ClientControllerEvents, + ClientControllerMessenger, +} from './ClientController'; +export type { ClientControllerSetUiOpenAction } from './ClientController-method-action-types'; diff --git a/packages/client-controller/src/selectors.ts b/packages/client-controller/src/selectors.ts new file mode 100644 index 00000000000..a8cefc6c5a3 --- /dev/null +++ b/packages/client-controller/src/selectors.ts @@ -0,0 +1,18 @@ +import type { ClientControllerState } from './ClientController'; + +/** + * Selects whether the UI is currently open. + * + * @param state - The ClientController state. + * @returns True if the UI is open. + */ +const selectIsUiOpen = (state: ClientControllerState): boolean => + state.isUiOpen; + +/** + * Selectors for the ClientController state. + * These can be used with Redux or directly with controller state. + */ +export const clientControllerSelectors = { + selectIsUiOpen, +}; diff --git a/packages/client-controller/tsconfig.build.json b/packages/client-controller/tsconfig.build.json new file mode 100644 index 00000000000..931c4d6594b --- /dev/null +++ b/packages/client-controller/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/client-controller/tsconfig.json b/packages/client-controller/tsconfig.json new file mode 100644 index 00000000000..3184a4bfde9 --- /dev/null +++ b/packages/client-controller/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { "baseUrl": "." }, + "include": ["src"] +} diff --git a/packages/client-controller/typedoc.json b/packages/client-controller/typedoc.json new file mode 100644 index 00000000000..d02905868c6 --- /dev/null +++ b/packages/client-controller/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "plugin": ["typedoc-plugin-missing-exports"], + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/compliance-controller/CHANGELOG.md b/packages/compliance-controller/CHANGELOG.md new file mode 100644 index 00000000000..5ad9c2a1366 --- /dev/null +++ b/packages/compliance-controller/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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 ([#7945](https://github.com/MetaMask/core/pull/7945)) + - Add `ComplianceController` for managing OFAC compliance state for wallet addresses. + - Add `ComplianceService` for fetching compliance data from the Compliance API. + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/compliance-controller/LICENSE b/packages/compliance-controller/LICENSE new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/compliance-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/compliance-controller/README.md b/packages/compliance-controller/README.md new file mode 100644 index 00000000000..57c20d19629 --- /dev/null +++ b/packages/compliance-controller/README.md @@ -0,0 +1,80 @@ +# `@metamask/compliance-controller` + +Manages OFAC compliance checks for wallet addresses by interfacing with the Compliance API. + +## Overview + +This package provides: + +- **`ComplianceService`** — A data service that communicates with the Compliance API to check whether wallet addresses are sanctioned under OFAC regulations. +- **`ComplianceController`** — A controller that manages compliance state, caching wallet compliance results and blocked wallet lists. + +## Installation + +`yarn add @metamask/compliance-controller` + +or + +`npm install @metamask/compliance-controller` + +## Usage + +```typescript +import { Messenger } from '@metamask/messenger'; +import { + ComplianceController, + ComplianceService, +} from '@metamask/compliance-controller'; +import type { + ComplianceControllerActions, + ComplianceControllerEvents, + ComplianceServiceActions, + ComplianceServiceEvents, +} from '@metamask/compliance-controller'; + +// Set up the root messenger +const rootMessenger = new Messenger< + 'Root', + ComplianceServiceActions | ComplianceControllerActions, + ComplianceServiceEvents | ComplianceControllerEvents +>({ namespace: 'Root' }); + +// Create service messenger and service +const serviceMessenger = new Messenger({ + namespace: 'ComplianceService', + parent: rootMessenger, +}); +new ComplianceService({ + messenger: serviceMessenger, + fetch, + env: 'production', +}); + +// Create controller messenger and controller +const controllerMessenger = new Messenger({ + namespace: 'ComplianceController', + parent: rootMessenger, +}); +const controller = new ComplianceController({ + messenger: controllerMessenger, +}); + +// Check a single wallet +await rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + '0x1234...', +); + +// Check multiple wallets +await rootMessenger.call('ComplianceController:checkWalletsCompliance', [ + '0x1234...', + '0x5678...', +]); + +// Fetch the full blocked wallets list +await rootMessenger.call('ComplianceController:updateBlockedWallets'); +``` + +## 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/compliance-controller/jest.config.js b/packages/compliance-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/compliance-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/compliance-controller/package.json b/packages/compliance-controller/package.json new file mode 100644 index 00000000000..7c534c32ffd --- /dev/null +++ b/packages/compliance-controller/package.json @@ -0,0 +1,77 @@ +{ + "name": "@metamask/compliance-controller", + "version": "0.0.0", + "description": "Manages OFAC compliance checks for wallet addresses", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/compliance-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/compliance-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/compliance-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": "^9.0.0", + "@metamask/controller-utils": "^11.18.0", + "@metamask/messenger": "^0.3.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.9.0", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.4.4", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "nock": "^13.3.1", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/compliance-controller/src/ComplianceController-method-action-types.ts b/packages/compliance-controller/src/ComplianceController-method-action-types.ts new file mode 100644 index 00000000000..8a0a84d94b8 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceController-method-action-types.ts @@ -0,0 +1,69 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ComplianceController } from './ComplianceController'; + +/** + * Initializes the controller by fetching the blocked wallets list if it + * is missing or stale. Call once after construction to ensure the blocklist + * is ready for `selectIsWalletBlocked` lookups. + */ +export type ComplianceControllerInitAction = { + type: `ComplianceController:init`; + handler: ComplianceController['init']; +}; + +/** + * Checks compliance status for a single wallet address via the API and + * persists the result to state. + * + * @param address - The wallet address to check. + * @returns The compliance status of the wallet. + */ +export type ComplianceControllerCheckWalletComplianceAction = { + type: `ComplianceController:checkWalletCompliance`; + handler: ComplianceController['checkWalletCompliance']; +}; + +/** + * Checks compliance status for multiple wallet addresses via the API and + * persists the results to state. + * + * @param addresses - The wallet addresses to check. + * @returns The compliance statuses of the wallets. + */ +export type ComplianceControllerCheckWalletsComplianceAction = { + type: `ComplianceController:checkWalletsCompliance`; + handler: ComplianceController['checkWalletsCompliance']; +}; + +/** + * Fetches the full list of blocked wallets from the API and persists the + * data to state. This also updates the `blockedWalletsLastFetched` timestamp. + * + * @returns The blocked wallets information. + */ +export type ComplianceControllerUpdateBlockedWalletsAction = { + type: `ComplianceController:updateBlockedWallets`; + handler: ComplianceController['updateBlockedWallets']; +}; + +/** + * Clears all compliance data from state. + */ +export type ComplianceControllerClearComplianceStateAction = { + type: `ComplianceController:clearComplianceState`; + handler: ComplianceController['clearComplianceState']; +}; + +/** + * Union of all ComplianceController action types. + */ +export type ComplianceControllerMethodActions = + | ComplianceControllerInitAction + | ComplianceControllerCheckWalletComplianceAction + | ComplianceControllerCheckWalletsComplianceAction + | ComplianceControllerUpdateBlockedWalletsAction + | ComplianceControllerClearComplianceStateAction; diff --git a/packages/compliance-controller/src/ComplianceController.test.ts b/packages/compliance-controller/src/ComplianceController.test.ts new file mode 100644 index 00000000000..4d525697f00 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceController.test.ts @@ -0,0 +1,640 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { ComplianceController } from './ComplianceController'; +import type { ComplianceControllerMessenger } from './ComplianceController'; +import { selectIsWalletBlocked } from './selectors'; + +const MOCK_BLOCKED_WALLETS_RESPONSE = { + addresses: ['0xBLOCKED_A', '0xBLOCKED_B'], + sources: { ofac: 100, remote: 5 }, + lastUpdated: '2026-01-15T00:00:00.000Z', +}; + +describe('ComplianceController', () => { + describe('constructor', () => { + it('accepts initial state', async () => { + const givenState = { + walletComplianceStatusMap: { + '0xABC123': { + address: '0xABC123', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + blockedWallets: null, + blockedWalletsLastFetched: 0, + lastCheckedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { options: { state: givenState } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(givenState); + }, + ); + }); + + it('fills in missing initial state with defaults', async () => { + await withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + { + "blockedWallets": null, + "blockedWalletsLastFetched": 0, + "lastCheckedAt": null, + "walletComplianceStatusMap": {}, + } + `); + }); + }); + }); + + describe('init', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('fetches the blocked wallets list when it has never been fetched', async () => { + await withController(async ({ controller, rootMessenger }) => { + const updateBlockedWallets = jest.fn( + async () => MOCK_BLOCKED_WALLETS_RESPONSE, + ); + rootMessenger.registerActionHandler( + 'ComplianceService:updateBlockedWallets', + updateBlockedWallets, + ); + + await controller.init(); + + expect(updateBlockedWallets).toHaveBeenCalledTimes(1); + expect(controller.state.blockedWallets).toStrictEqual({ + ...MOCK_BLOCKED_WALLETS_RESPONSE, + fetchedAt: '2026-02-01T00:00:00.000Z', + }); + }); + }); + + it('fetches the blocked wallets list when the cache is stale', async () => { + const oneHourAgo = Date.now() - 60 * 60 * 1000 - 1; + await withController( + { + options: { + state: { blockedWalletsLastFetched: oneHourAgo }, + }, + }, + async ({ controller, rootMessenger }) => { + const updateBlockedWallets = jest.fn( + async () => MOCK_BLOCKED_WALLETS_RESPONSE, + ); + rootMessenger.registerActionHandler( + 'ComplianceService:updateBlockedWallets', + updateBlockedWallets, + ); + + await controller.init(); + + expect(updateBlockedWallets).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('does not fetch when the cache is fresh', async () => { + await withController( + { + options: { + state: { blockedWalletsLastFetched: Date.now() }, + }, + }, + async ({ controller, rootMessenger }) => { + const updateBlockedWallets = jest.fn( + async () => MOCK_BLOCKED_WALLETS_RESPONSE, + ); + rootMessenger.registerActionHandler( + 'ComplianceService:updateBlockedWallets', + updateBlockedWallets, + ); + + await controller.init(); + + expect(updateBlockedWallets).not.toHaveBeenCalled(); + }, + ); + }); + + it('respects a custom blockedWalletsRefreshInterval', async () => { + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 - 1; + await withController( + { + options: { + state: { blockedWalletsLastFetched: fiveMinutesAgo }, + blockedWalletsRefreshInterval: 5 * 60 * 1000, + }, + }, + async ({ controller, rootMessenger }) => { + const updateBlockedWallets = jest.fn( + async () => MOCK_BLOCKED_WALLETS_RESPONSE, + ); + rootMessenger.registerActionHandler( + 'ComplianceService:updateBlockedWallets', + updateBlockedWallets, + ); + + await controller.init(); + + expect(updateBlockedWallets).toHaveBeenCalledTimes(1); + }, + ); + }); + }); + + describe('ComplianceController:init', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does the same thing as the direct method', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:updateBlockedWallets', + async () => MOCK_BLOCKED_WALLETS_RESPONSE, + ); + + await rootMessenger.call('ComplianceController:init'); + + expect(controller.state.blockedWallets).toStrictEqual({ + ...MOCK_BLOCKED_WALLETS_RESPONSE, + fetchedAt: '2026-02-01T00:00:00.000Z', + }); + }); + }); + }); + + describe('selectIsWalletBlocked', () => { + it('returns true if the wallet is in the cached blocklist', async () => { + await withController( + { + options: { + state: { + blockedWallets: { + addresses: ['0xBLOCKED_A', '0xBLOCKED_B'], + sources: { ofac: 2, remote: 0 }, + lastUpdated: '2026-01-01T00:00:00.000Z', + fetchedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('0xBLOCKED_A')(controller.state)).toBe( + true, + ); + }, + ); + }); + + it('returns true if the wallet was checked on-demand and found blocked', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + '0xON_DEMAND': { + address: '0xON_DEMAND', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('0xON_DEMAND')(controller.state)).toBe( + true, + ); + }, + ); + }); + + it('returns false if the wallet is not in the blocklist or status map', async () => { + await withController(({ controller }) => { + expect(selectIsWalletBlocked('0xUNKNOWN')(controller.state)).toBe( + false, + ); + }); + }); + + it('returns false if the wallet is in the status map but not blocked', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + '0xSAFE': { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('0xSAFE')(controller.state)).toBe(false); + }, + ); + }); + + it('returns false if the blocklist is null and the address is unknown', async () => { + await withController(({ controller }) => { + expect(selectIsWalletBlocked('0xANYTHING')(controller.state)).toBe( + false, + ); + }); + }); + + it('performs case-sensitive lookup', async () => { + await withController( + { + options: { + state: { + blockedWallets: { + addresses: ['0xABC'], + sources: { ofac: 1, remote: 0 }, + lastUpdated: '2026-01-01T00:00:00.000Z', + fetchedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('0xAbC')(controller.state)).toBe(false); + }, + ); + }); + }); + + describe('ComplianceController:checkWalletCompliance', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls the service, persists the result to state, and returns the status', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async (address) => ({ + address, + blocked: true, + }), + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: true, + checkedAt: '2026-02-01T00:00:00.000Z', + }); + expect(controller.state.walletComplianceStatusMap).toStrictEqual({ + '0xABC123': { + address: '0xABC123', + blocked: true, + checkedAt: '2026-02-01T00:00:00.000Z', + }, + }); + expect(controller.state.lastCheckedAt).toBe('2026-02-01T00:00:00.000Z'); + }); + }); + }); + + describe('ComplianceController:checkWalletsCompliance', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls the service, persists all results to state, and returns statuses', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async (addresses) => + addresses.map((addr) => ({ + address: addr, + blocked: addr === '0xBLOCKED', + })), + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletsCompliance', + ['0xSAFE', '0xBLOCKED'], + ); + + expect(result).toStrictEqual([ + { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-02-01T00:00:00.000Z', + }, + { + address: '0xBLOCKED', + blocked: true, + checkedAt: '2026-02-01T00:00:00.000Z', + }, + ]); + expect(controller.state.walletComplianceStatusMap).toStrictEqual({ + '0xSAFE': { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-02-01T00:00:00.000Z', + }, + '0xBLOCKED': { + address: '0xBLOCKED', + blocked: true, + checkedAt: '2026-02-01T00:00:00.000Z', + }, + }); + }); + }); + }); + + describe('ComplianceController:updateBlockedWallets', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls the service, persists data to state, and updates the lastFetched timestamp', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:updateBlockedWallets', + async () => MOCK_BLOCKED_WALLETS_RESPONSE, + ); + + const result = await rootMessenger.call( + 'ComplianceController:updateBlockedWallets', + ); + + expect(result).toStrictEqual({ + ...MOCK_BLOCKED_WALLETS_RESPONSE, + fetchedAt: '2026-02-01T00:00:00.000Z', + }); + expect(controller.state.blockedWallets).toStrictEqual({ + ...MOCK_BLOCKED_WALLETS_RESPONSE, + fetchedAt: '2026-02-01T00:00:00.000Z', + }); + expect(controller.state.blockedWalletsLastFetched).toBeGreaterThan(0); + expect(controller.state.lastCheckedAt).toBe('2026-02-01T00:00:00.000Z'); + }); + }); + }); + + describe('ComplianceController:clearComplianceState', () => { + it('resets all compliance data to defaults', async () => { + const givenState = { + walletComplianceStatusMap: { + '0xABC': { + address: '0xABC', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + blockedWallets: { + addresses: ['0xABC'], + sources: { ofac: 10, remote: 1 }, + lastUpdated: '2026-01-01T00:00:00.000Z', + fetchedAt: '2026-01-01T00:00:00.000Z', + }, + blockedWalletsLastFetched: 1000, + lastCheckedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { options: { state: givenState } }, + ({ controller, rootMessenger }) => { + rootMessenger.call('ComplianceController:clearComplianceState'); + + expect(controller.state).toStrictEqual({ + walletComplianceStatusMap: {}, + blockedWallets: null, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }); + }, + ); + }); + }); + + describe('clearComplianceState', () => { + it('does the same thing as the messenger action', async () => { + const givenState = { + walletComplianceStatusMap: { + '0xABC': { + address: '0xABC', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + blockedWalletsLastFetched: 1000, + lastCheckedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { options: { state: givenState } }, + ({ controller }) => { + controller.clearComplianceState(); + + expect(controller.state).toStrictEqual({ + walletComplianceStatusMap: {}, + blockedWallets: null, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }); + }, + ); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + }); + + it('includes expected state in state logs', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "blockedWalletsLastFetched": 0, + "lastCheckedAt": null, + } + `); + }); + }); + + it('persists expected state', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "blockedWallets": null, + "blockedWalletsLastFetched": 0, + "lastCheckedAt": null, + "walletComplianceStatusMap": {}, + } + `); + }); + }); + + it('exposes expected state to UI', async () => { + await withController(({ controller }) => { + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "walletComplianceStatusMap": {}, + } + `); + }); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the controller under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * The callback that `withController` calls. + */ +type WithControllerCallback = (payload: { + controller: ComplianceController; + rootMessenger: RootMessenger; + messenger: ComplianceControllerMessenger; +}) => Promise | ReturnValue; + +/** + * The options bag that `withController` takes. + */ +type WithControllerOptions = { + options: Partial[0]>; +}; + +/** + * Constructs the messenger populated with all external actions and events + * required by the controller under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); +} + +/** + * Constructs the messenger for the controller under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The controller-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ComplianceControllerMessenger { + const messenger: ComplianceControllerMessenger = new Messenger({ + namespace: 'ComplianceController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'ComplianceService:checkWalletCompliance', + 'ComplianceService:checkWalletsCompliance', + 'ComplianceService:updateBlockedWallets', + ], + events: [], + messenger, + }); + return messenger; +} + +/** + * Wrap tests for the controller under test by ensuring that the controller is + * created ahead of time and then safely destroyed afterward as needed. + * + * @param args - Either a function, or an options bag + a function. The options + * bag contains arguments for the controller constructor. All constructor + * arguments are optional and will be filled in with defaults as needed + * (including `messenger`). The function is called with the new + * controller, root messenger, and controller messenger. + * @returns The same return value as the given function. + */ +async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const controller = new ComplianceController({ + messenger, + ...options, + }); + return await testFunction({ controller, rootMessenger, messenger }); +} diff --git a/packages/compliance-controller/src/ComplianceController.ts b/packages/compliance-controller/src/ComplianceController.ts new file mode 100644 index 00000000000..65eecad7be3 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceController.ts @@ -0,0 +1,345 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { ComplianceControllerMethodActions } from './ComplianceController-method-action-types'; +import type { ComplianceServiceMethodActions } from './ComplianceService-method-action-types'; +import type { BlockedWalletsInfo, WalletComplianceStatus } from './types'; + +// === GENERAL === + +/** + * The name of the {@link ComplianceController}, used to namespace the + * controller's actions and events and to namespace the controller's state data + * when composed with other controllers. + */ +export const controllerName = 'ComplianceController'; + +/** + * The default refresh interval for the blocked wallets list (1 hour). + */ +const DEFAULT_BLOCKED_WALLETS_REFRESH_INTERVAL = 60 * 60 * 1000; + +// === STATE === + +/** + * Describes the shape of the state object for {@link ComplianceController}. + */ +export type ComplianceControllerState = { + /** + * A map of wallet addresses to their on-demand compliance check results. + */ + walletComplianceStatusMap: Record; + + /** + * Information about all blocked wallets, or `null` if not yet fetched. + */ + blockedWallets: BlockedWalletsInfo | null; + + /** + * Timestamp (in milliseconds) of the last blocked wallets fetch, or 0 if + * never fetched. + */ + blockedWalletsLastFetched: number; + + /** + * The date/time (in ISO-8601 format) when the last compliance check was + * performed, or `null` if no checks have been performed yet. + */ + lastCheckedAt: string | null; +}; + +/** + * The metadata for each property in {@link ComplianceControllerState}. + */ +const complianceControllerMetadata = { + walletComplianceStatusMap: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: true, + }, + blockedWallets: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: false, + }, + blockedWalletsLastFetched: { + includeInDebugSnapshot: false, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + lastCheckedAt: { + includeInDebugSnapshot: false, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link ComplianceController} 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 ComplianceController} state. + */ +export function getDefaultComplianceControllerState(): ComplianceControllerState { + return { + walletComplianceStatusMap: {}, + blockedWallets: null, + blockedWalletsLastFetched: 0, + lastCheckedAt: null, + }; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'init', + 'checkWalletCompliance', + 'checkWalletsCompliance', + 'updateBlockedWallets', + 'clearComplianceState', +] as const; + +/** + * Retrieves the state of the {@link ComplianceController}. + */ +export type ComplianceControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ComplianceControllerState +>; + +/** + * Actions that {@link ComplianceController} exposes to other consumers. + */ +export type ComplianceControllerActions = + | ComplianceControllerGetStateAction + | ComplianceControllerMethodActions; + +/** + * Actions from other messengers that {@link ComplianceController} calls. + */ +type AllowedActions = ComplianceServiceMethodActions; + +/** + * Published when the state of {@link ComplianceController} changes. + */ +export type ComplianceControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ComplianceControllerState +>; + +/** + * Events that {@link ComplianceController} exposes to other consumers. + */ +export type ComplianceControllerEvents = ComplianceControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ComplianceController} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link ComplianceController}. + */ +export type ComplianceControllerMessenger = Messenger< + typeof controllerName, + ComplianceControllerActions | AllowedActions, + ComplianceControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * `ComplianceController` manages OFAC compliance state for wallet addresses. + * It proactively fetches and caches the blocked wallets list from the + * Compliance API so that consumers can perform synchronous lookups via the + * `selectIsWalletBlocked` selector without making API calls. + */ +export class ComplianceController extends BaseController< + typeof controllerName, + ComplianceControllerState, + ComplianceControllerMessenger +> { + /** + * The interval (in milliseconds) after which the blocked wallets list + * is considered stale. + */ + readonly #blockedWalletsRefreshInterval: number; + + /** + * Constructs a new {@link ComplianceController}. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this controller. + * @param args.state - The desired state with which to init this + * controller. Missing properties will be filled in with defaults. + * @param args.blockedWalletsRefreshInterval - The interval in milliseconds + * after which the blocked wallets list is considered stale. Defaults to 1 + * hour. + */ + constructor({ + messenger, + state, + blockedWalletsRefreshInterval = DEFAULT_BLOCKED_WALLETS_REFRESH_INTERVAL, + }: { + messenger: ComplianceControllerMessenger; + state?: Partial; + blockedWalletsRefreshInterval?: number; + }) { + super({ + messenger, + metadata: complianceControllerMetadata, + name: controllerName, + state: { + ...getDefaultComplianceControllerState(), + ...state, + }, + }); + + this.#blockedWalletsRefreshInterval = blockedWalletsRefreshInterval; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Initializes the controller by fetching the blocked wallets list if it + * is missing or stale. Call once after construction to ensure the blocklist + * is ready for `selectIsWalletBlocked` lookups. + */ + async init(): Promise { + if (this.#isBlockedWalletsStale()) { + await this.updateBlockedWallets(); + } + } + + /** + * Checks compliance status for a single wallet address via the API and + * persists the result to state. + * + * @param address - The wallet address to check. + * @returns The compliance status of the wallet. + */ + async checkWalletCompliance( + address: string, + ): Promise { + const result = await this.messenger.call( + 'ComplianceService:checkWalletCompliance', + address, + ); + + const now = new Date().toISOString(); + const status: WalletComplianceStatus = { + address: result.address, + blocked: result.blocked, + checkedAt: now, + }; + + this.update((draftState) => { + draftState.walletComplianceStatusMap[address] = status; + draftState.lastCheckedAt = now; + }); + + return status; + } + + /** + * Checks compliance status for multiple wallet addresses via the API and + * persists the results to state. + * + * @param addresses - The wallet addresses to check. + * @returns The compliance statuses of the wallets. + */ + async checkWalletsCompliance( + addresses: string[], + ): Promise { + const results = await this.messenger.call( + 'ComplianceService:checkWalletsCompliance', + addresses, + ); + + const now = new Date().toISOString(); + const statuses: WalletComplianceStatus[] = results.map((result) => ({ + address: result.address, + blocked: result.blocked, + checkedAt: now, + })); + + this.update((draftState) => { + for (let idx = 0; idx < statuses.length; idx++) { + const callerAddress = addresses[idx]; + draftState.walletComplianceStatusMap[callerAddress] = statuses[idx]; + } + draftState.lastCheckedAt = now; + }); + + return statuses; + } + + /** + * Fetches the full list of blocked wallets from the API and persists the + * data to state. This also updates the `blockedWalletsLastFetched` timestamp. + * + * @returns The blocked wallets information. + */ + async updateBlockedWallets(): Promise { + const result = await this.messenger.call( + 'ComplianceService:updateBlockedWallets', + ); + + const now = new Date().toISOString(); + const blockedWallets: BlockedWalletsInfo = { + addresses: result.addresses, + sources: result.sources, + lastUpdated: result.lastUpdated, + fetchedAt: now, + }; + + this.update((draftState) => { + draftState.blockedWallets = blockedWallets; + draftState.blockedWalletsLastFetched = Date.now(); + draftState.lastCheckedAt = now; + }); + + return blockedWallets; + } + + /** + * Clears all compliance data from state. + */ + clearComplianceState(): void { + this.update((draftState) => { + draftState.walletComplianceStatusMap = {}; + draftState.blockedWallets = null; + draftState.blockedWalletsLastFetched = 0; + draftState.lastCheckedAt = null; + }); + } + + /** + * Determines whether the blocked wallets list is stale and needs to be + * refreshed. + * + * @returns `true` if the list has never been fetched or the refresh + * interval has elapsed. + */ + #isBlockedWalletsStale(): boolean { + return ( + Date.now() - this.state.blockedWalletsLastFetched >= + this.#blockedWalletsRefreshInterval + ); + } +} diff --git a/packages/compliance-controller/src/ComplianceService-method-action-types.ts b/packages/compliance-controller/src/ComplianceService-method-action-types.ts new file mode 100644 index 00000000000..f13fc5bfbdf --- /dev/null +++ b/packages/compliance-controller/src/ComplianceService-method-action-types.ts @@ -0,0 +1,46 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ComplianceService } from './ComplianceService'; + +/** + * Checks compliance status for a single wallet address. + * + * @param address - The wallet address to check. + * @returns The compliance status of the wallet. + */ +export type ComplianceServiceCheckWalletComplianceAction = { + type: `ComplianceService:checkWalletCompliance`; + handler: ComplianceService['checkWalletCompliance']; +}; + +/** + * Checks compliance status for multiple wallet addresses in a single request. + * + * @param addresses - The wallet addresses to check. + * @returns The compliance statuses of the wallets. + */ +export type ComplianceServiceCheckWalletsComplianceAction = { + type: `ComplianceService:checkWalletsCompliance`; + handler: ComplianceService['checkWalletsCompliance']; +}; + +/** + * Fetches the full list of blocked wallets and source metadata. + * + * @returns The blocked wallets data. + */ +export type ComplianceServiceUpdateBlockedWalletsAction = { + type: `ComplianceService:updateBlockedWallets`; + handler: ComplianceService['updateBlockedWallets']; +}; + +/** + * Union of all ComplianceService action types. + */ +export type ComplianceServiceMethodActions = + | ComplianceServiceCheckWalletComplianceAction + | ComplianceServiceCheckWalletsComplianceAction + | ComplianceServiceUpdateBlockedWalletsAction; diff --git a/packages/compliance-controller/src/ComplianceService.test.ts b/packages/compliance-controller/src/ComplianceService.test.ts new file mode 100644 index 00000000000..37f744d7bf5 --- /dev/null +++ b/packages/compliance-controller/src/ComplianceService.test.ts @@ -0,0 +1,435 @@ +import { HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock from 'nock'; + +import type { ComplianceServiceMessenger } from './ComplianceService'; +import { ComplianceService } from './ComplianceService'; + +const MOCK_API_URL = 'https://compliance.dev-api.cx.metamask.io'; + +describe('ComplianceService', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('ComplianceService:checkWalletCompliance', () => { + it('returns the compliance status for a single wallet address', async () => { + nock(MOCK_API_URL).get('/v1/wallet/0xABC123').reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService(); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('returns blocked status for a sanctioned wallet', async () => { + nock(MOCK_API_URL).get('/v1/wallet/0xSANCTIONED').reply(200, { + address: '0xSANCTIONED', + blocked: true, + }); + const { rootMessenger } = getService(); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xSANCTIONED', + ); + + expect(result).toStrictEqual({ + address: '0xSANCTIONED', + blocked: true, + }); + }); + + it.each([ + 'not an object', + { missing: 'address' }, + { address: 123, blocked: true }, + { address: '0xABC', blocked: 'not a boolean' }, + { address: '0xABC' }, + { blocked: true }, + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock(MOCK_API_URL) + .get('/v1/wallet/0xABC123') + .reply(200, JSON.stringify(response)); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ), + ).rejects.toThrow( + 'Malformed response received from compliance wallet check API', + ); + }, + ); + + it('throws an HttpError when the API returns a non-200 status', async () => { + nock(MOCK_API_URL).get('/v1/wallet/0xABC123').times(4).reply(404); + const { service } = getService(); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + + await expect(service.checkWalletCompliance('0xABC123')).rejects.toThrow( + /failed with status '404'/u, + ); + }); + + it('calls onDegraded listeners if the request takes longer than 5 seconds', async () => { + nock(MOCK_API_URL) + .get('/v1/wallet/0xABC123') + .reply(200, () => { + jest.advanceTimersByTime(6000); + return { address: '0xABC123', blocked: false }; + }); + const { service, rootMessenger } = getService(); + const onDegradedListener = jest.fn(); + service.onDegraded(onDegradedListener); + + await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(onDegradedListener).toHaveBeenCalled(); + }); + + it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { + nock(MOCK_API_URL).get('/v1/wallet/0xABC123').times(4).reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + + await expect( + rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ), + ).rejects.toThrow(/failed with status '500'/u); + }); + + it('intercepts requests and throws a circuit break error after the 4th failed attempt', async () => { + nock(MOCK_API_URL).get('/v1/wallet/0xABC123').times(12).reply(500); + const { service, rootMessenger } = getService(); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + const onBreakListener = jest.fn(); + service.onBreak(onBreakListener); + + // Each call attempts 4 requests before failing + await expect( + rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ), + ).rejects.toThrow(/failed with status '500'/u); + await expect( + rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ), + ).rejects.toThrow(/failed with status '500'/u); + await expect( + rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ), + ).rejects.toThrow(/failed with status '500'/u); + // Circuit breaker opens + await expect( + rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ), + ).rejects.toThrow( + 'Execution prevented because the circuit breaker is open', + ); + expect(onBreakListener).toHaveBeenCalledWith({ + error: expect.any(HttpError), + }); + }); + }); + + describe('ComplianceService:checkWalletsCompliance', () => { + it('returns compliance statuses for multiple addresses', async () => { + const addresses = ['0xABC', '0xDEF']; + nock(MOCK_API_URL) + .post('/v1/wallet/batch', JSON.stringify(addresses)) + .reply(200, [ + { address: '0xABC', blocked: false }, + { address: '0xDEF', blocked: true }, + ]); + const { rootMessenger } = getService(); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletsCompliance', + addresses, + ); + + expect(result).toStrictEqual([ + { address: '0xABC', blocked: false }, + { address: '0xDEF', blocked: true }, + ]); + }); + + it.each([ + 'not an array', + [{ missing: 'address', blocked: true }], + [{ address: '0xABC', blocked: 'not boolean' }], + [{ address: 123, blocked: true }], + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock(MOCK_API_URL) + .post('/v1/wallet/batch') + .reply(200, JSON.stringify(response)); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('ComplianceService:checkWalletsCompliance', [ + '0xABC', + ]), + ).rejects.toThrow( + 'Malformed response received from compliance batch check API', + ); + }, + ); + + it('throws an HttpError when the API returns a non-200 status', async () => { + nock(MOCK_API_URL).post('/v1/wallet/batch').times(4).reply(500); + const { service } = getService(); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + + await expect(service.checkWalletsCompliance(['0xABC'])).rejects.toThrow( + /failed with status '500'/u, + ); + }); + }); + + describe('ComplianceService:updateBlockedWallets', () => { + it('returns the blocked wallets data', async () => { + nock(MOCK_API_URL) + .get('/v1/blocked-wallets') + .reply(200, { + addresses: ['0xABC', '0xDEF'], + sources: { ofac: 100, remote: 5 }, + lastUpdated: '2026-01-01T00:00:00.000Z', + }); + const { rootMessenger } = getService(); + + const result = await rootMessenger.call( + 'ComplianceService:updateBlockedWallets', + ); + + expect(result).toStrictEqual({ + addresses: ['0xABC', '0xDEF'], + sources: { ofac: 100, remote: 5 }, + lastUpdated: '2026-01-01T00:00:00.000Z', + }); + }); + + it.each([ + 'not an object', + { + addresses: 'not an array', + sources: { ofac: 1, remote: 1 }, + lastUpdated: '2026-01-01', + }, + { + addresses: ['0xABC'], + sources: 'not an object', + lastUpdated: '2026-01-01', + }, + { + addresses: ['0xABC'], + sources: { ofac: 'nan', remote: 1 }, + lastUpdated: '2026-01-01', + }, + { + addresses: ['0xABC'], + sources: { ofac: 1, remote: 'nan' }, + lastUpdated: '2026-01-01', + }, + { + addresses: ['0xABC'], + sources: { ofac: 1, remote: 1 }, + lastUpdated: 123, + }, + { addresses: ['0xABC'], sources: { ofac: 1, remote: 1 } }, + { + addresses: [123], + sources: { ofac: 1, remote: 1 }, + lastUpdated: '2026-01-01', + }, + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock(MOCK_API_URL) + .get('/v1/blocked-wallets') + .reply(200, JSON.stringify(response)); + const { rootMessenger } = getService(); + + await expect( + rootMessenger.call('ComplianceService:updateBlockedWallets'), + ).rejects.toThrow( + 'Malformed response received from compliance blocked wallets API', + ); + }, + ); + + it('throws an HttpError when the API returns a non-200 status', async () => { + nock(MOCK_API_URL).get('/v1/blocked-wallets').times(4).reply(503); + const { service } = getService(); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(console.error); + }); + + await expect(service.updateBlockedWallets()).rejects.toThrow( + /failed with status '503'/u, + ); + }); + }); + + describe('checkWalletCompliance', () => { + it('does the same thing as the messenger action', async () => { + nock(MOCK_API_URL).get('/v1/wallet/0xABC123').reply(200, { + address: '0xABC123', + blocked: false, + }); + const { service } = getService(); + + const result = await service.checkWalletCompliance('0xABC123'); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + }); + + describe('checkWalletsCompliance', () => { + it('does the same thing as the messenger action', async () => { + const addresses = ['0xABC']; + nock(MOCK_API_URL) + .post('/v1/wallet/batch', JSON.stringify(addresses)) + .reply(200, [{ address: '0xABC', blocked: true }]); + const { service } = getService(); + + const result = await service.checkWalletsCompliance(addresses); + + expect(result).toStrictEqual([{ address: '0xABC', blocked: true }]); + }); + }); + + describe('updateBlockedWallets', () => { + it('does the same thing as the messenger action', async () => { + nock(MOCK_API_URL) + .get('/v1/blocked-wallets') + .reply(200, { + addresses: ['0xABC'], + sources: { ofac: 50, remote: 2 }, + lastUpdated: '2026-02-01T00:00:00.000Z', + }); + const { service } = getService(); + + const result = await service.updateBlockedWallets(); + + expect(result).toStrictEqual({ + addresses: ['0xABC'], + sources: { ofac: 50, remote: 2 }, + lastUpdated: '2026-02-01T00:00:00.000Z', + }); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the service's messenger. + * @returns The service-specific messenger. + */ +function getMessenger( + rootMessenger: RootMessenger, +): ComplianceServiceMessenger { + return new Messenger({ + namespace: 'ComplianceService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.options - The options that the service constructor takes. All are + * optional and will be filled in with defaults as needed (including + * `messenger`). + * @returns The new service, root messenger, and service messenger. + */ +function getService({ + options = {}, +}: { + options?: Partial[0]>; +} = {}): { + service: ComplianceService; + rootMessenger: RootMessenger; + messenger: ComplianceServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new ComplianceService({ + fetch, + messenger, + env: 'development', + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/compliance-controller/src/ComplianceService.ts b/packages/compliance-controller/src/ComplianceService.ts new file mode 100644 index 00000000000..bd95787e6ce --- /dev/null +++ b/packages/compliance-controller/src/ComplianceService.ts @@ -0,0 +1,369 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Infer } from '@metamask/superstruct'; +import { array, boolean, number, object, string } from '@metamask/superstruct'; +import type { IDisposable } from 'cockatiel'; + +import type { ComplianceServiceMethodActions } from './ComplianceService-method-action-types'; + +// === GENERAL === + +/** + * The name of the {@link ComplianceService}, used to namespace the service's + * actions and events. + */ +export const serviceName = 'ComplianceService'; + +/** + * The supported environments for the Compliance API. + */ +export type ComplianceServiceEnvironment = 'production' | 'development'; + +const COMPLIANCE_API_URLS: Record = { + production: 'https://compliance.api.cx.metamask.io', + development: 'https://compliance.dev-api.cx.metamask.io', +}; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'checkWalletCompliance', + 'checkWalletsCompliance', + 'updateBlockedWallets', +] as const; + +/** + * Actions that {@link ComplianceService} exposes to other consumers. + */ +export type ComplianceServiceActions = ComplianceServiceMethodActions; + +/** + * Actions from other messengers that {@link ComplianceService} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link ComplianceService} exposes to other consumers. + */ +export type ComplianceServiceEvents = never; + +/** + * Events from other messengers that {@link ComplianceService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link ComplianceService}. + */ +export type ComplianceServiceMessenger = Messenger< + typeof serviceName, + ComplianceServiceActions | AllowedActions, + ComplianceServiceEvents | AllowedEvents +>; + +// === API RESPONSE SCHEMAS === + +/** + * Schema for the response from `GET /v1/wallet/:address`. + */ +const WalletCheckResponseStruct = object({ + address: string(), + blocked: boolean(), +}); + +/** + * The validated shape of a single wallet compliance check response. + */ +type WalletCheckResponse = Infer; + +/** + * Schema for each item in the response from `POST /v1/wallet/batch`. + * Reuses the same shape as a single wallet check. + */ +const BatchWalletCheckResponseItemStruct = WalletCheckResponseStruct; + +/** + * The validated shape of a single item in a batch compliance check response. + */ +type BatchWalletCheckResponseItem = Infer< + typeof BatchWalletCheckResponseItemStruct +>; + +/** + * Schema for the response from `GET /v1/blocked-wallets`. + */ +const BlockedWalletsResponseStruct = object({ + addresses: array(string()), + sources: object({ + ofac: number(), + remote: number(), + }), + lastUpdated: string(), +}); + +/** + * The validated shape of the blocked wallets response. + */ +type BlockedWalletsResponse = Infer; + +// === SERVICE DEFINITION === + +/** + * `ComplianceService` communicates with the Compliance API to check whether + * wallet addresses are sanctioned under OFAC regulations. + * + * @example + * + * ``` ts + * import { Messenger } from '@metamask/messenger'; + * import type { + * ComplianceServiceActions, + * ComplianceServiceEvents, + * } from '@metamask/compliance-controller'; + * import { ComplianceService } from '@metamask/compliance-controller'; + * + * const rootMessenger = new Messenger< + * 'Root', + * ComplianceServiceActions, + * ComplianceServiceEvents, + * >({ namespace: 'Root' }); + * const serviceMessenger = new Messenger< + * 'ComplianceService', + * ComplianceServiceActions, + * ComplianceServiceEvents, + * typeof rootMessenger, + * >({ + * namespace: 'ComplianceService', + * parent: rootMessenger, + * }); + * new ComplianceService({ + * messenger: serviceMessenger, + * fetch, + * env: 'production', + * }); + * + * // Check a single wallet + * const result = await rootMessenger.call( + * 'ComplianceService:checkWalletCompliance', + * '0x1234...', + * ); + * // => { address: '0x1234...', blocked: false } + * ``` + */ +export class ComplianceService { + /** + * The name of the service. + */ + readonly name: typeof serviceName; + + /** + * The messenger suited for this service. + */ + readonly #messenger: ConstructorParameters< + typeof ComplianceService + >[0]['messenger']; + + /** + * A function that can be used to make an HTTP request. + */ + readonly #fetch: ConstructorParameters[0]['fetch']; + + /** + * The resolved base URL for the Compliance API. + */ + readonly #complianceApiUrl: string; + + /** + * The policy that wraps each request. + * + * @see {@link createServicePolicy} + */ + readonly #policy: ServicePolicy; + + /** + * Constructs a new ComplianceService object. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.fetch - A function that can be used to make an HTTP request. + * @param args.env - The environment to use for the Compliance API. Determines + * the base URL. + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + fetch: fetchFunction, + env, + policyOptions = {}, + }: { + messenger: ComplianceServiceMessenger; + fetch: typeof fetch; + env: ComplianceServiceEnvironment; + policyOptions?: CreateServicePolicyOptions; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#complianceApiUrl = COMPLIANCE_API_URLS[env]; + this.#policy = createServicePolicy(policyOptions); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Registers a handler that will be called after a request returns a non-500 + * response, causing a retry. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onRetry(listener: Parameters[0]): IDisposable { + return this.#policy.onRetry(listener); + } + + /** + * Registers a handler that will be called after a set number of retry rounds + * prove that requests to the API endpoint consistently return a 5xx response. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onBreak(listener: Parameters[0]): IDisposable { + return this.#policy.onBreak(listener); + } + + /** + * Registers a handler that will be called when the service is degraded due + * to slow responses or repeated failures. + * + * @param listener - The handler to be called. + * @returns An object that can be used to unregister the handler. + * @see {@link createServicePolicy} + */ + onDegraded( + listener: Parameters[0], + ): IDisposable { + return this.#policy.onDegraded(listener); + } + + /** + * Checks compliance status for a single wallet address. + * + * @param address - The wallet address to check. + * @returns The compliance status of the wallet. + */ + async checkWalletCompliance(address: string): Promise { + const response = await this.#policy.execute(async () => { + const url = new URL( + `/v1/wallet/${encodeURIComponent(address)}`, + this.#complianceApiUrl, + ); + const localResponse = await this.#fetch(url); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse; + }); + const jsonResponse: unknown = await response.json(); + + return validateResponse( + jsonResponse, + WalletCheckResponseStruct, + 'compliance wallet check API', + ); + } + + /** + * Checks compliance status for multiple wallet addresses in a single request. + * + * @param addresses - The wallet addresses to check. + * @returns The compliance statuses of the wallets. + */ + async checkWalletsCompliance( + addresses: string[], + ): Promise { + const response = await this.#policy.execute(async () => { + const url = new URL('/v1/wallet/batch', this.#complianceApiUrl); + const localResponse = await this.#fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(addresses), + }); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse; + }); + const jsonResponse: unknown = await response.json(); + + return validateResponse( + jsonResponse, + array(BatchWalletCheckResponseItemStruct), + 'compliance batch check API', + ); + } + + /** + * Fetches the full list of blocked wallets and source metadata. + * + * @returns The blocked wallets data. + */ + async updateBlockedWallets(): Promise { + const response = await this.#policy.execute(async () => { + const url = new URL('/v1/blocked-wallets', this.#complianceApiUrl); + const localResponse = await this.#fetch(url); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse; + }); + const jsonResponse: unknown = await response.json(); + + return validateResponse( + jsonResponse, + BlockedWalletsResponseStruct, + 'compliance blocked wallets API', + ); + } +} + +/** + * Validates an API response against a superstruct schema. + * + * @param data - The raw response data to validate. + * @param struct - The superstruct schema to validate against. + * @param struct.is - The type guard function from the schema. + * @param apiName - A human-readable name for the API, used in error messages. + * @returns The validated data. + * @throws If the data does not match the schema. + */ +function validateResponse( + data: unknown, + struct: { is: (value: unknown) => value is Response }, + apiName: string, +): Response { + if (struct.is(data)) { + return data; + } + throw new Error(`Malformed response received from ${apiName}`); +} diff --git a/packages/compliance-controller/src/index.ts b/packages/compliance-controller/src/index.ts new file mode 100644 index 00000000000..e5a324e8725 --- /dev/null +++ b/packages/compliance-controller/src/index.ts @@ -0,0 +1,33 @@ +export type { + ComplianceServiceActions, + ComplianceServiceEnvironment, + ComplianceServiceEvents, + ComplianceServiceMessenger, +} from './ComplianceService'; +export type { + ComplianceServiceCheckWalletComplianceAction, + ComplianceServiceCheckWalletsComplianceAction, + ComplianceServiceUpdateBlockedWalletsAction, +} from './ComplianceService-method-action-types'; +export { ComplianceService } from './ComplianceService'; +export type { + ComplianceControllerActions, + ComplianceControllerEvents, + ComplianceControllerGetStateAction, + ComplianceControllerMessenger, + ComplianceControllerState, + ComplianceControllerStateChangeEvent, +} from './ComplianceController'; +export type { + ComplianceControllerCheckWalletComplianceAction, + ComplianceControllerCheckWalletsComplianceAction, + ComplianceControllerClearComplianceStateAction, + ComplianceControllerUpdateBlockedWalletsAction, + ComplianceControllerInitAction, +} from './ComplianceController-method-action-types'; +export { + ComplianceController, + getDefaultComplianceControllerState, +} from './ComplianceController'; +export { selectIsWalletBlocked } from './selectors'; +export type { WalletComplianceStatus, BlockedWalletsInfo } from './types'; diff --git a/packages/compliance-controller/src/selectors.ts b/packages/compliance-controller/src/selectors.ts new file mode 100644 index 00000000000..b003af241c3 --- /dev/null +++ b/packages/compliance-controller/src/selectors.ts @@ -0,0 +1,34 @@ +import { createSelector } from 'reselect'; + +import type { ComplianceControllerState } from './ComplianceController'; + +const selectBlockedWallets = ( + state: ComplianceControllerState, +): ComplianceControllerState['blockedWallets'] => state.blockedWallets; + +const selectWalletComplianceStatusMap = ( + state: ComplianceControllerState, +): ComplianceControllerState['walletComplianceStatusMap'] => + state.walletComplianceStatusMap; + +/** + * Creates a selector that returns whether a wallet address is blocked, based + * on the cached blocklist. The lookup checks the proactively fetched blocklist + * first, then falls back to the per-address compliance status map. + * + * @param address - The wallet address to check. + * @returns A selector that takes `ComplianceControllerState` and returns + * `true` if the wallet is blocked, `false` otherwise. + */ +export const selectIsWalletBlocked = ( + address: string, +): ((state: ComplianceControllerState) => boolean) => + createSelector( + [selectBlockedWallets, selectWalletComplianceStatusMap], + (blockedWallets, statusMap): boolean => { + if (blockedWallets?.addresses.includes(address)) { + return true; + } + return statusMap[address]?.blocked ?? false; + }, + ); diff --git a/packages/compliance-controller/src/types.ts b/packages/compliance-controller/src/types.ts new file mode 100644 index 00000000000..ac934efaed5 --- /dev/null +++ b/packages/compliance-controller/src/types.ts @@ -0,0 +1,48 @@ +/** + * The result of checking a single wallet address for compliance. + */ +export type WalletComplianceStatus = { + /** + * The wallet address that was checked. + */ + address: string; + + /** + * Whether the wallet address is blocked. + */ + blocked: boolean; + + /** + * The date/time (in ISO-8601 format) when this check was performed. + */ + checkedAt: string; +}; + +/** + * Information about the full set of blocked wallets returned by the API. + */ +export type BlockedWalletsInfo = { + /** + * The list of all blocked wallet addresses. + */ + addresses: string[]; + + /** + * The number of blocked addresses from each source. + */ + sources: { + ofac: number; + remote: number; + }; + + /** + * The date/time (in ISO-8601 format) when the blocklist was last updated + * on the server. + */ + lastUpdated: string; + + /** + * The date/time (in ISO-8601 format) when this data was fetched. + */ + fetchedAt: string; +}; diff --git a/packages/compliance-controller/tsconfig.build.json b/packages/compliance-controller/tsconfig.build.json new file mode 100644 index 00000000000..5a5c9e2326a --- /dev/null +++ b/packages/compliance-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": "../controller-utils/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/compliance-controller/tsconfig.json b/packages/compliance-controller/tsconfig.json new file mode 100644 index 00000000000..972cb2e8c25 --- /dev/null +++ b/packages/compliance-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "include": ["../../types", "./src"], + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../messenger" } + ] +} diff --git a/packages/compliance-controller/typedoc.json b/packages/compliance-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/compliance-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/composable-controller/package.json b/packages/composable-controller/package.json index b6cf87adae5..963b48e4b09 100644 --- a/packages/composable-controller/package.json +++ b/packages/composable-controller/package.json @@ -55,13 +55,12 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/json-rpc-engine": "^10.2.2", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "immer": "^9.0.6", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index 0325f18aa46..fccf473b871 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -15,7 +15,6 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; import type { Patch } from 'immer'; -import sinon from 'sinon'; import type { ChildControllerStateChangeEvents, @@ -149,10 +148,6 @@ type ControllersMap = { }; describe('ComposableController', () => { - afterEach(() => { - sinon.restore(); - }); - describe('BaseController', () => { it('should compose controller state', () => { type ComposableControllerState = { @@ -253,15 +248,15 @@ describe('ComposableController', () => { messenger: composableControllerMessenger, }); - const listener = sinon.stub(); + const listener = jest.fn(); composableControllerMessenger.subscribe( 'ComposableController:stateChange', listener, ); fooController.updateFoo('qux'); - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toStrictEqual({ FooController: { foo: 'qux', }, @@ -331,12 +326,12 @@ describe('ComposableController', () => { messenger: composableControllerMessenger, }); - const listener = sinon.stub(); + const listener = jest.fn(); messenger.subscribe('ComposableController:stateChange', listener); fooController.updateFoo('qux'); - expect(listener.calledOnce).toBe(true); - expect(listener.getCall(0).args[0]).toStrictEqual({ + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toStrictEqual({ QuzController: { quz: 'quz', }, @@ -551,8 +546,8 @@ describe('ComposableController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "FooController": Object { + { + "FooController": { "foo": "foo", }, } @@ -612,7 +607,7 @@ describe('ComposableController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -669,8 +664,8 @@ describe('ComposableController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "FooController": Object { + { + "FooController": { "foo": "foo", }, } @@ -730,7 +725,7 @@ describe('ComposableController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/connectivity-controller/package.json b/packages/connectivity-controller/package.json index 45c3b29fada..e8d76a0f4ad 100644 --- a/packages/connectivity-controller/package.json +++ b/packages/connectivity-controller/package.json @@ -54,11 +54,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/connectivity-controller/src/ConnectivityController.test.ts b/packages/connectivity-controller/src/ConnectivityController.test.ts index e22ac19b70c..f7a864594f1 100644 --- a/packages/connectivity-controller/src/ConnectivityController.test.ts +++ b/packages/connectivity-controller/src/ConnectivityController.test.ts @@ -60,7 +60,7 @@ describe('ConnectivityController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "connectivityStatus": "online", } `); @@ -76,7 +76,7 @@ describe('ConnectivityController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "connectivityStatus": "online", } `); @@ -91,7 +91,7 @@ describe('ConnectivityController', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -104,7 +104,7 @@ describe('ConnectivityController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "connectivityStatus": "online", } `); diff --git a/packages/controller-utils/jest.environment.js b/packages/controller-utils/jest.environment.js index e53bbe624df..35f9c47b2ad 100644 --- a/packages/controller-utils/jest.environment.js +++ b/packages/controller-utils/jest.environment.js @@ -1,9 +1,9 @@ -const JSDOMEnvironment = require('jest-environment-jsdom'); +const { TestEnvironment } = require('jest-environment-jsdom'); // Custom test environment copied from https://github.com/jsdom/jsdom/issues/2524 // in order to add TextEncoder to jsdom. TextEncoder is expected by @noble/hashes. -module.exports = class CustomTestEnvironment extends JSDOMEnvironment { +module.exports = class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); if (typeof this.global.TextEncoder === 'undefined') { diff --git a/packages/controller-utils/package.json b/packages/controller-utils/package.json index 3397b51a581..7d37a03adb5 100644 --- a/packages/controller-utils/package.json +++ b/packages/controller-utils/package.json @@ -64,15 +64,14 @@ "@babel/runtime": "^7.23.9", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.191", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/controller-utils/src/create-service-policy.test.ts b/packages/controller-utils/src/create-service-policy.test.ts index 11efc7fe909..7d487617197 100644 --- a/packages/controller-utils/src/create-service-policy.test.ts +++ b/packages/controller-utils/src/create-service-policy.test.ts @@ -1,6 +1,4 @@ import { CircuitState, handleWhen } from 'cockatiel'; -import { useFakeTimers } from 'sinon'; -import type { SinonFakeTimers } from 'sinon'; import { createServicePolicy, @@ -11,14 +9,12 @@ import { } from './create-service-policy'; describe('createServicePolicy', () => { - let clock: SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('wrapping a service that succeeds on the first try', () => { @@ -102,7 +98,7 @@ describe('createServicePolicy', () => { policy.onDegraded(onDegradedListener); const promise = policy.execute(mockService); - clock.tick(delay); + jest.advanceTimersByTime(delay); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -120,7 +116,7 @@ describe('createServicePolicy', () => { policy.onAvailable(onAvailableListener); const promise = policy.execute(mockService); - clock.tick(delay); + jest.advanceTimersByTime(delay); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -201,7 +197,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).not.toHaveBeenCalled(); @@ -224,7 +220,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -247,7 +243,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -272,11 +268,11 @@ describe('createServicePolicy', () => { // 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); + jest.advanceTimersByTimeAsync(0); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(176.27932892814937); + jest.advanceTimersByTimeAsync(176.27932892814937); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(186.8886145345685); + jest.advanceTimersByTimeAsync(186.8886145345685); await ignoreRejection(promise); expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); @@ -295,7 +291,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onRetryListener).toHaveBeenCalledTimes(DEFAULT_MAX_RETRIES); @@ -313,7 +309,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -332,7 +328,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).not.toHaveBeenCalled(); @@ -351,7 +347,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -371,7 +367,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -394,7 +390,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -416,7 +412,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).not.toHaveBeenCalled(); @@ -438,7 +434,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -461,7 +457,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -483,7 +479,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -505,7 +501,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -528,7 +524,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -550,7 +546,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -570,7 +566,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); const secondExecution = policy.execute(mockService); @@ -597,7 +593,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow( new Error( @@ -623,7 +619,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -646,7 +642,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -668,7 +664,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -693,15 +689,15 @@ describe('createServicePolicy', () => { // 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); + jest.advanceTimersByTimeAsync(0); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(176.27932892814937); + jest.advanceTimersByTimeAsync(176.27932892814937); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(186.8886145345685); + jest.advanceTimersByTimeAsync(186.8886145345685); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(366.8287823691078); + jest.advanceTimersByTimeAsync(366.8287823691078); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(731.8792783578953); + jest.advanceTimersByTimeAsync(731.8792783578953); await ignoreRejection(promise); expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); @@ -723,7 +719,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onRetryListener).toHaveBeenCalledTimes(maxRetries); @@ -743,7 +739,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -763,7 +759,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).not.toHaveBeenCalled(); @@ -783,7 +779,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -804,7 +800,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -824,7 +820,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -844,7 +840,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -865,7 +861,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -885,7 +881,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -903,7 +899,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); const secondExecution = policy.execute(mockService); @@ -927,7 +923,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow( new Error( @@ -951,7 +947,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -972,7 +968,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -992,7 +988,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1018,7 +1014,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -1042,7 +1038,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).not.toHaveBeenCalled(); @@ -1066,7 +1062,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -1091,7 +1087,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1115,7 +1111,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow(error); }); @@ -1139,7 +1135,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -1164,7 +1160,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -1188,7 +1184,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1210,7 +1206,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); const secondExecution = policy.execute(mockService); @@ -1239,7 +1235,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow( new Error( @@ -1267,7 +1263,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -1292,7 +1288,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -1316,7 +1312,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1352,11 +1348,11 @@ describe('createServicePolicy', () => { // 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); + jest.advanceTimersByTimeAsync(0); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(176.27932892814937); + jest.advanceTimersByTimeAsync(176.27932892814937); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(186.8886145345685); + jest.advanceTimersByTimeAsync(186.8886145345685); await promise; expect(mockService).toHaveBeenCalledTimes(1 + DEFAULT_MAX_RETRIES); @@ -1378,7 +1374,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -1401,7 +1397,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -1437,7 +1433,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -1463,13 +1459,13 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise1; const promise2 = 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(); + jest.runAllTimersAsync(); await promise2; expect(onAvailableListener).toHaveBeenCalledTimes(1); @@ -1498,7 +1494,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -1525,7 +1521,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1554,7 +1550,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -1580,7 +1576,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -1620,7 +1616,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -1647,13 +1643,13 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise1; const promise2 = 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(); + jest.runAllTimersAsync(); await promise2; expect(onAvailableListener).toHaveBeenCalledTimes(1); @@ -1686,7 +1682,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -1717,7 +1713,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1745,7 +1741,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -1772,7 +1768,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -1813,7 +1809,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -1841,13 +1837,13 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise1; const promise2 = 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(); + jest.runAllTimersAsync(); await promise2; expect(onAvailableListener).toHaveBeenCalledTimes(1); @@ -1880,7 +1876,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -1911,7 +1907,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -1940,7 +1936,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow( new Error( 'Execution prevented because the circuit breaker is open', @@ -1970,7 +1966,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -1998,7 +1994,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -2025,7 +2021,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -2067,9 +2063,9 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); - clock.tick(duration); + jest.advanceTimersByTime(duration); const result = await policy.execute(mockService); expect(result).toStrictEqual({ some: 'data' }); @@ -2097,9 +2093,9 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); - clock.tick(duration); + jest.advanceTimersByTime(duration); await policy.execute(mockService); await policy.execute(mockService); @@ -2132,15 +2128,15 @@ describe('createServicePolicy', () => { // 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); + jest.advanceTimersByTimeAsync(0); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(176.27932892814937); + jest.advanceTimersByTimeAsync(176.27932892814937); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(186.8886145345685); + jest.advanceTimersByTimeAsync(186.8886145345685); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(366.8287823691078); + jest.advanceTimersByTimeAsync(366.8287823691078); // eslint-disable-next-line @typescript-eslint/no-floating-promises - clock.tickAsync(731.8792783578953); + jest.advanceTimersByTimeAsync(731.8792783578953); await promise; expect(mockService).toHaveBeenCalledTimes(1 + maxRetries); @@ -2165,7 +2161,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -2190,7 +2186,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -2228,7 +2224,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -2253,7 +2249,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; await policy.execute(mockService); @@ -2284,7 +2280,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -2312,7 +2308,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -2339,7 +2335,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -2364,7 +2360,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -2402,7 +2398,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -2427,7 +2423,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; await policy.execute(mockService); @@ -2458,7 +2454,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -2486,7 +2482,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -2513,7 +2509,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await expect(promise).rejects.toThrow( new Error( @@ -2542,7 +2538,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -2568,7 +2564,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -2593,7 +2589,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -2632,9 +2628,9 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); - clock.tick(duration); + jest.advanceTimersByTime(duration); const result = await policy.execute(mockService); expect(result).toStrictEqual({ some: 'data' }); @@ -2659,9 +2655,9 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); - clock.tick(duration); + jest.advanceTimersByTime(duration); await policy.execute(mockService); await policy.execute(mockService); @@ -2695,7 +2691,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -2724,7 +2720,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -2767,7 +2763,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -2797,13 +2793,13 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise1; const promise2 = 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(); + jest.runAllTimersAsync(); await promise2; expect(onAvailableListener).toHaveBeenCalledTimes(1); @@ -2838,7 +2834,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -2871,7 +2867,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -2902,7 +2898,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); expect(await promise).toStrictEqual({ some: 'data' }); }); @@ -2931,7 +2927,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onBreakListener).not.toHaveBeenCalled(); @@ -2974,7 +2970,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).not.toHaveBeenCalled(); @@ -3004,13 +3000,13 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise1; const promise2 = 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(); + jest.runAllTimersAsync(); await promise2; expect(onAvailableListener).toHaveBeenCalledTimes(1); @@ -3045,7 +3041,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onDegradedListener).toHaveBeenCalledTimes(1); @@ -3078,7 +3074,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await promise; expect(onAvailableListener).not.toHaveBeenCalled(); @@ -3109,7 +3105,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); await expect(promise).rejects.toThrow( @@ -3143,7 +3139,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onBreakListener).toHaveBeenCalledTimes(1); @@ -3173,7 +3169,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onDegradedListener).not.toHaveBeenCalled(); @@ -3202,7 +3198,7 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(promise); expect(onAvailableListener).not.toHaveBeenCalled(); @@ -3246,9 +3242,9 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); - clock.tick(duration); + jest.advanceTimersByTime(duration); const result = await policy.execute(mockService); expect(result).toStrictEqual({ some: 'data' }); @@ -3278,9 +3274,9 @@ describe('createServicePolicy', () => { // 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(); + jest.runAllTimersAsync(); await ignoreRejection(firstExecution); - clock.tick(duration); + jest.advanceTimersByTime(duration); await policy.execute(mockService); await policy.execute(mockService); @@ -3345,7 +3341,7 @@ describe('createServicePolicy', () => { ...optionsWithCircuitBreakDuration, }); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); policy.onAvailable(onAvailableListener); @@ -3357,7 +3353,7 @@ describe('createServicePolicy', () => { await ignoreRejection(policy.execute(mockService)); await ignoreRejection(policy.execute(mockService)); await ignoreRejection(policy.execute(mockService)); - clock.tick(circuitBreakDuration); + jest.advanceTimersByTime(circuitBreakDuration); await policy.execute(mockService); expect(onAvailableListener).toHaveBeenCalledTimes(2); @@ -3378,7 +3374,7 @@ describe('createServicePolicy', () => { ...optionsWithCircuitBreakDuration, }); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); policy.onAvailable(onAvailableListener); @@ -3390,7 +3386,7 @@ describe('createServicePolicy', () => { await ignoreRejection(policy.execute(mockService)); await ignoreRejection(policy.execute(mockService)); await ignoreRejection(policy.execute(mockService)); - clock.tick(circuitBreakDuration); + jest.advanceTimersByTime(circuitBreakDuration); await ignoreRejection(policy.execute(mockService)); expect(onAvailableListener).toHaveBeenCalledTimes(1); @@ -3408,13 +3404,13 @@ describe('createServicePolicy', () => { }; const policy = createServicePolicy(); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); // Retry until we break the circuit await ignoreRejection(policy.execute(mockService)); await ignoreRejection(policy.execute(mockService)); await ignoreRejection(policy.execute(mockService)); - clock.tick(1000); + jest.advanceTimersByTime(1000); expect(policy.getRemainingCircuitOpenDuration()).toBe( DEFAULT_CIRCUIT_BREAK_DURATION - 1000, @@ -3435,7 +3431,7 @@ describe('createServicePolicy', () => { }; const policy = createServicePolicy(); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); expect(policy.getCircuitState()).toBe(CircuitState.Closed); @@ -3446,7 +3442,7 @@ describe('createServicePolicy', () => { await ignoreRejection(policy.execute(mockService)); expect(policy.getCircuitState()).toBe(CircuitState.Open); - clock.tick(DEFAULT_CIRCUIT_BREAK_DURATION); + jest.advanceTimersByTime(DEFAULT_CIRCUIT_BREAK_DURATION); const promise = ignoreRejection(policy.execute(mockService)); expect(policy.getCircuitState()).toBe(CircuitState.HalfOpen); await promise; @@ -3466,7 +3462,7 @@ describe('createServicePolicy', () => { }); const policy = createServicePolicy(); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); // Retry until we break the circuit await ignoreRejection(policy.execute(mockService)); @@ -3490,7 +3486,7 @@ describe('createServicePolicy', () => { }); const policy = createServicePolicy(); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); // Retry until we break the circuit await ignoreRejection(policy.execute(mockService)); @@ -3517,7 +3513,7 @@ describe('createServicePolicy', () => { const onAvailableListener = jest.fn(); const policy = createServicePolicy(); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); policy.onAvailable(onAvailableListener); @@ -3542,7 +3538,7 @@ describe('createServicePolicy', () => { }); const policy = createServicePolicy(); policy.onRetry(() => { - clock.next(); + jest.advanceTimersToNextTimer(); }); // Retry until we break the circuit await ignoreRejection(policy.execute(mockService)); diff --git a/packages/controller-utils/src/index.test.ts b/packages/controller-utils/src/index.test.ts index 1167bdcdfad..7980c44b7c7 100644 --- a/packages/controller-utils/src/index.test.ts +++ b/packages/controller-utils/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/controller-utils', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "BrokenCircuitError", "CircuitState", "CockatielEventEmitter", diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index b436d031b59..d0c2f81dfab 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,8 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `ApiPlatformClientService` to expose `ApiPlatformClient` via the messenger without a controller ([#7928](https://github.com/MetaMask/core/pull/7928)) + - Consumers call `messenger.call('ApiPlatformClientService:getApiPlatformClient')` to obtain the shared client for accounts, prices, token, and tokens APIs +- Export TanStack Query options for all API endpoints via `get*QueryOptions` helpers ([#7928](https://github.com/MetaMask/core/pull/7928)) + - Each fetch method (e.g. `fetchV5MultiAccountBalances`) has a corresponding `get*QueryOptions` (e.g. `getV5MultiAccountBalancesQueryOptions`) returning the same options object used internally + - Enables reuse with `useQuery`, `useInfiniteQuery`, `useSuspenseQuery`, and other TanStack Query APIs +- Extend `FetchOptions` to allow TanStack Query options (e.g. `select`, `initialPageParam`, `retry`, `initialData`) to be passed through to `get*QueryOptions` and merged into the returned query options + - Export `getQueryOptionsOverrides` helper for stripping `queryKey`/`queryFn` from options when merging + - All API clients (accounts, prices, token, tokens) merge user overrides first, then apply `staleTime`/`gcTime` defaults so cache timing is consistent and extra options (e.g. `select`) are preserved + +### Changed + +- **BREAKING:** Merge `fetchV2BalancesWithOptions` into `fetchV2Balances` ([#7928](https://github.com/MetaMask/core/pull/7928)) + - `fetchV2Balances(address, queryOptions?, options?)` now accepts the full query options: `networks`, `filterSupportedTokens`, `includeTokenAddresses`, `includeStakedAssets` + - `getV2BalancesQueryOptions` accepts the same full query options for use with TanStack Query + - `fetchV2BalancesWithOptions` and `getV2BalancesWithOptionsQueryOptions` have been removed; use `fetchV2Balances` and `getV2BalancesQueryOptions` with the desired options instead +- **BREAKING:** Align v4 multi-account transactions with API ([#7928](https://github.com/MetaMask/core/pull/7928)) + - First parameter renamed from `accountIds` to `accountAddresses` in `fetchV4MultiAccountTransactions` and `getV4MultiAccountTransactionsQueryOptions` + - Query options now include: `startTimestamp`, `endTimestamp`, `limit`, `after`, `before`, `maxLogsPerTx`, `lang` in addition to `networks`, `cursor`, `sortDirection`, `includeLogs`, `includeTxMetadata` + - `includeValueTransfers` has been removed from the options (not in API spec) +- Accounts, prices, and tokens clients: `fetch*` and `get*QueryOptions` now short-circuit on empty required inputs (e.g. empty address, empty account IDs or asset lists) and return empty results without calling the API ([#7928](https://github.com/MetaMask/core/pull/7928)) + +## [5.1.1] + ### Changed +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/profile-sync-controller` from `^27.0.0` to `^27.1.0` ([#7849](https://github.com/MetaMask/core/pull/7849)) ## [5.1.0] @@ -190,7 +216,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Type definitions** - Comprehensive TypeScript types for transactions, balances, WebSocket messages, and service configurations - **Logging infrastructure** - Structured logging with module-specific loggers for debugging and monitoring -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/core-backend@5.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/core-backend@5.1.1...HEAD +[5.1.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@5.1.0...@metamask/core-backend@5.1.1 [5.1.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@5.0.0...@metamask/core-backend@5.1.0 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@4.1.0...@metamask/core-backend@5.0.0 [4.1.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@4.0.0...@metamask/core-backend@4.1.0 diff --git a/packages/core-backend/README.md b/packages/core-backend/README.md index e1d3cbcbab0..cc1d8945bd1 100644 --- a/packages/core-backend/README.md +++ b/packages/core-backend/README.md @@ -449,80 +449,85 @@ const pricesClient = new PricesApiClient({ ### API Clients +Optional parameters: `options` is `FetchOptions` (e.g. `staleTime`, `gcTime`). `queryOptions` are API-specific filters (e.g. `networks`, `cursor`). Each `fetch*` method has a matching `get*QueryOptions` that returns the TanStack Query options object for use with `useQuery`, `useInfiniteQuery`, `useSuspenseQuery`, etc. + #### AccountsApiClient Handles account-related operations including balances, transactions, NFTs, and token discovery. -| Method | Description | -| ------------------------------------------------------- | ------------------------------------------ | -| `fetchV1SupportedNetworks()` | Get supported networks (v1) | -| `fetchV2SupportedNetworks()` | Get supported networks (v2) | -| `fetchV2ActiveNetworks(accountIds, options?)` | Get active networks by CAIP-10 account IDs | -| `fetchV2Balances(address, options?)` | Get balances for single address | -| `fetchV2BalancesWithOptions(address, options?)` | Get balances with filters | -| `fetchV4MultiAccountBalances(addresses, options?)` | Get balances for multiple addresses | -| `fetchV5MultiAccountBalances(accountIds, options?)` | Get balances using CAIP-10 IDs | -| `fetchV1TransactionByHash(chainId, txHash, options?)` | Get transaction by hash | -| `fetchV1AccountTransactions(address, options?)` | Get account transactions | -| `fetchV4MultiAccountTransactions(accountIds, options?)` | Get multi-account transactions | -| `fetchV1AccountRelationship(chainId, from, to)` | Get address relationship | -| `fetchV2AccountNfts(address, options?)` | Get account NFTs | -| `fetchV2AccountTokens(address, options?)` | Get detected ERC20 tokens | -| `invalidateBalances()` | Invalidate all balance cache | -| `invalidateAccounts()` | Invalidate all account cache | +| Method | Description | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `fetchV1SupportedNetworks(options?)` | Get supported networks (v1) | +| `fetchV2SupportedNetworks(options?)` | Get supported networks (v2) | +| `fetchV2ActiveNetworks(accountIds, queryOptions?, options?)` | Get active networks by CAIP-10 account IDs | +| `fetchV2Balances(address, queryOptions?, options?)` | Get balances for single address (supports networks, filterSupportedTokens, includeTokenAddresses, includeStakedAssets) | +| `fetchV4MultiAccountBalances(addresses, queryOptions?, options?)` | Get balances for multiple addresses | +| `fetchV5MultiAccountBalances(accountIds, queryOptions?, options?)` | Get balances using CAIP-10 IDs | +| `fetchV1TransactionByHash(chainId, txHash, queryOptions?, options?)` | Get transaction by hash | +| `fetchV1AccountTransactions(address, queryOptions?, options?)` | Get account transactions | +| `fetchV4MultiAccountTransactions(accountAddresses, queryOptions?, options?)` | Get multi-account transactions | +| `fetchV1AccountRelationship(chainId, from, to, options?)` | Get address relationship | +| `fetchV2AccountNfts(address, queryOptions?, options?)` | Get account NFTs | +| `fetchV2AccountTokens(address, queryOptions?, options?)` | Get detected ERC20 tokens | +| `getV1SupportedNetworksQueryOptions(options?)` … `getV2AccountTokensQueryOptions(...)` | Return TanStack Query options for each fetch (use with useQuery, useInfiniteQuery, etc.) | +| `invalidateBalances()` | Invalidate all balance cache | +| `invalidateAccounts()` | Invalidate all account cache | #### PricesApiClient Handles price-related operations including spot prices, exchange rates, and historical data. -| Method | Description | -| ----------------------------------------------------------------------- | ------------------------------------------------ | -| `fetchPriceV1SupportedNetworks()` | Get price-supported networks (v1) | -| `fetchPriceV2SupportedNetworks()` | Get price-supported networks in CAIP format (v2) | -| `fetchV1ExchangeRates(baseCurrency)` | Get exchange rates for base currency | -| `fetchV1FiatExchangeRates()` | Get fiat exchange rates | -| `fetchV1CryptoExchangeRates()` | Get crypto exchange rates | -| `fetchV1SpotPricesByCoinIds(coinIds)` | Get spot prices by CoinGecko IDs | -| `fetchV1SpotPriceByCoinId(coinId, currency?)` | Get single coin spot price | -| `fetchV1TokenPrices(chainId, addresses, options?)` | Get token prices on chain | -| `fetchV1TokenPrice(chainId, address, currency?)` | Get single token price | -| `fetchV2SpotPrices(chainId, addresses, options?)` | Get spot prices with market data | -| `fetchV3SpotPrices(assetIds, options?)` | Get spot prices by CAIP-19 asset IDs | -| `fetchV1HistoricalPricesByCoinId(coinId, options?)` | Get historical prices by CoinGecko ID | -| `fetchV1HistoricalPricesByTokenAddresses(chainId, addresses, options?)` | Get historical prices for tokens | -| `fetchV1HistoricalPrices(chainId, address, options?)` | Get historical prices for single token | -| `fetchV3HistoricalPrices(chainId, assetType, options?)` | Get historical prices by CAIP-19 | -| `fetchV1HistoricalPriceGraphByCoinId(coinId, options?)` | Get price graph by CoinGecko ID | -| `fetchV1HistoricalPriceGraphByTokenAddress(chainId, address, options?)` | Get price graph by token address | -| `invalidatePrices()` | Invalidate all price cache | +| Method | Description | +| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `fetchPriceV1SupportedNetworks(options?)` | Get price-supported networks (v1) | +| `fetchPriceV2SupportedNetworks(options?)` | Get price-supported networks in CAIP format (v2) | +| `fetchV1ExchangeRates(baseCurrency, options?)` | Get exchange rates for base currency | +| `fetchV1FiatExchangeRates(options?)` | Get fiat exchange rates | +| `fetchV1CryptoExchangeRates(options?)` | Get crypto exchange rates | +| `fetchV1SpotPricesByCoinIds(coinIds, options?)` | Get spot prices by CoinGecko IDs | +| `fetchV1SpotPriceByCoinId(coinId, currency?, options?)` | Get single coin spot price | +| `fetchV1TokenPrices(chainId, addresses, queryOptions?, options?)` | Get token prices on chain | +| `fetchV1TokenPrice(chainId, address, currency?, options?)` | Get single token price | +| `fetchV2SpotPrices(chainId, addresses, queryOptions?, options?)` | Get spot prices with market data | +| `fetchV3SpotPrices(assetIds, queryOptions?, options?)` | Get spot prices by CAIP-19 asset IDs | +| `fetchV1HistoricalPricesByCoinId(coinId, queryOptions?, options?)` | Get historical prices by CoinGecko ID | +| `fetchV1HistoricalPricesByTokenAddresses(chainId, addresses, queryOptions?, options?)` | Get historical prices for tokens | +| `fetchV1HistoricalPrices(chainId, address, queryOptions?, options?)` | Get historical prices for single token | +| `fetchV3HistoricalPrices(chainId, assetType, queryOptions?, options?)` | Get historical prices by CAIP-19 | +| `fetchV1HistoricalPriceGraphByCoinId(coinId, queryOptions?, options?)` | Get price graph by CoinGecko ID | +| `fetchV1HistoricalPriceGraphByTokenAddress(chainId, address, queryOptions?, options?)` | Get price graph by token address | +| `getPriceV1SupportedNetworksQueryOptions(options?)` … `getV1HistoricalPriceGraphByTokenAddressQueryOptions(...)` | Return TanStack Query options for each fetch | +| `invalidatePrices()` | Invalidate all price cache | #### TokenApiClient Handles token metadata, lists, and trending/popular token discovery. -| Method | Description | -| -------------------------------------------------- | ------------------------------- | -| `fetchNetworks()` | Get all networks | -| `fetchNetworkByChainId(chainId)` | Get network by chain ID | -| `fetchTokenList(chainId, options?)` | Get token list for chain | -| `fetchV1TokenMetadata(chainId, address, options?)` | Get token metadata | -| `fetchTokenDescription(chainId, address)` | Get token description | -| `fetchV3TrendingTokens(chainIds, options?)` | Get trending tokens | -| `fetchV3TopGainers(chainIds, options?)` | Get top gainers/losers | -| `fetchV3PopularTokens(chainIds, options?)` | Get popular tokens | -| `fetchTopAssets(chainId)` | Get top assets for chain | -| `fetchV1SuggestedOccurrenceFloors()` | Get suggested occurrence floors | +| Method | Description | +| --------------------------------------------------------------------------------------- | -------------------------------------------- | +| `fetchNetworks(options?)` | Get all networks | +| `fetchNetworkByChainId(chainId, options?)` | Get network by chain ID | +| `fetchTokenList(chainId, queryOptions?, options?)` | Get token list for chain | +| `fetchV1TokenMetadata(chainId, address, queryOptions?, options?)` | Get token metadata | +| `fetchTokenDescription(chainId, address, options?)` | Get token description | +| `fetchV3TrendingTokens(chainIds, queryOptions?, options?)` | Get trending tokens | +| `fetchV3TopGainers(chainIds, queryOptions?, options?)` | Get top gainers/losers | +| `fetchV3PopularTokens(chainIds, queryOptions?, options?)` | Get popular tokens | +| `fetchTopAssets(chainId, options?)` | Get top assets for chain | +| `fetchV1SuggestedOccurrenceFloors(options?)` | Get suggested occurrence floors | +| `getNetworksQueryOptions(options?)` … `getV1SuggestedOccurrenceFloorsQueryOptions(...)` | Return TanStack Query options for each fetch | #### TokensApiClient Handles bulk token operations and supported network queries. -| Method | Description | -| --------------------------------- | ----------------------------------------------------------- | -| `fetchTokenV1SupportedNetworks()` | Get token-supported networks (v1) | -| `fetchTokenV2SupportedNetworks()` | Get token-supported networks with full/partial support (v2) | -| `fetchV3Assets(assetIds)` | Fetch assets by CAIP-19 IDs | -| `invalidateTokens()` | Invalidate all token cache | +| Method | Description | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------- | +| `fetchTokenV1SupportedNetworks(options?)` | Get token-supported networks (v1) | +| `fetchTokenV2SupportedNetworks(options?)` | Get token-supported networks with full/partial support (v2) | +| `fetchV3Assets(assetIds, queryOptions?, fetchOptions?)` | Fetch assets by CAIP-19 IDs | +| `getTokenV1SupportedNetworksQueryOptions(options?)` … `getV3AssetsQueryOptions(...)` | Return TanStack Query options for each fetch | +| `invalidateTokens()` | Invalidate all token cache | ### Configuration diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index 39b56d6d16c..fe2ddecc23c 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-backend", - "version": "5.1.0", + "version": "5.1.1", "description": "Core backend services for MetaMask", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/keyring-controller": "^25.1.0", "@metamask/messenger": "^0.3.0", @@ -60,12 +60,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/core-backend/src/ApiPlatformClientService-method-action-types.ts b/packages/core-backend/src/ApiPlatformClientService-method-action-types.ts new file mode 100644 index 00000000000..09096daa1a9 --- /dev/null +++ b/packages/core-backend/src/ApiPlatformClientService-method-action-types.ts @@ -0,0 +1,24 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { ApiPlatformClientService } from './ApiPlatformClientService'; + +/** + * Returns the shared ApiPlatformClient instance. + * + * Use this via the messenger: `messenger.call('ApiPlatformClientService:getApiPlatformClient')`. + * + * @returns The ApiPlatformClient instance (accounts, prices, token, tokens). + */ +export type ApiPlatformClientServiceGetApiPlatformClientAction = { + type: `ApiPlatformClientService:getApiPlatformClient`; + handler: ApiPlatformClientService['getApiPlatformClient']; +}; + +/** + * Union of all ApiPlatformClientService action types. + */ +export type ApiPlatformClientServiceMethodActions = + ApiPlatformClientServiceGetApiPlatformClientAction; diff --git a/packages/core-backend/src/ApiPlatformClientService.test.ts b/packages/core-backend/src/ApiPlatformClientService.test.ts new file mode 100644 index 00000000000..8ff627f83cd --- /dev/null +++ b/packages/core-backend/src/ApiPlatformClientService.test.ts @@ -0,0 +1,92 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { ApiPlatformClientService } from './ApiPlatformClientService'; +import type { ApiPlatformClientServiceMessenger } from './ApiPlatformClientService'; + +type AllApiPlatformClientServiceActions = + MessengerActions; +type AllApiPlatformClientServiceEvents = + MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllApiPlatformClientServiceActions, + AllApiPlatformClientServiceEvents +>; + +function getRootMessenger(): RootMessenger { + return new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); +} + +describe('ApiPlatformClientService', () => { + describe('ApiPlatformClientService:getApiPlatformClient', () => { + it('returns the same ApiPlatformClient instance on each call', () => { + const rootMessenger = getRootMessenger(); + const serviceMessenger: ApiPlatformClientServiceMessenger = new Messenger< + 'ApiPlatformClientService', + AllApiPlatformClientServiceActions, + AllApiPlatformClientServiceEvents, + RootMessenger + >({ + namespace: 'ApiPlatformClientService', + parent: rootMessenger, + }); + + const _service = new ApiPlatformClientService({ + messenger: serviceMessenger, + clientProduct: 'test-product', + }); + expect(_service.name).toBe('ApiPlatformClientService'); + + const client1 = rootMessenger.call( + 'ApiPlatformClientService:getApiPlatformClient', + ); + const client2 = rootMessenger.call( + 'ApiPlatformClientService:getApiPlatformClient', + ); + + expect(client1).toBe(client2); + }); + + it('returns an ApiPlatformClient with accounts, prices, token, and tokens sub-clients', () => { + const rootMessenger = getRootMessenger(); + const serviceMessenger: ApiPlatformClientServiceMessenger = new Messenger< + 'ApiPlatformClientService', + AllApiPlatformClientServiceActions, + AllApiPlatformClientServiceEvents, + RootMessenger + >({ + namespace: 'ApiPlatformClientService', + parent: rootMessenger, + }); + + const _service = new ApiPlatformClientService({ + messenger: serviceMessenger, + clientProduct: 'test-product', + }); + expect(_service.name).toBe('ApiPlatformClientService'); + + const client = rootMessenger.call( + 'ApiPlatformClientService:getApiPlatformClient', + ); + + expect(client).toHaveProperty('accounts'); + expect(client).toHaveProperty('prices'); + expect(client).toHaveProperty('token'); + expect(client).toHaveProperty('tokens'); + expect(typeof client.accounts.fetchV5MultiAccountBalances).toBe( + 'function', + ); + expect(typeof client.prices.fetchV3SpotPrices).toBe('function'); + expect(typeof client.token.fetchTokenList).toBe('function'); + expect(typeof client.tokens.fetchV3Assets).toBe('function'); + }); + }); +}); diff --git a/packages/core-backend/src/ApiPlatformClientService.ts b/packages/core-backend/src/ApiPlatformClientService.ts new file mode 100644 index 00000000000..5aeca2fddc2 --- /dev/null +++ b/packages/core-backend/src/ApiPlatformClientService.ts @@ -0,0 +1,127 @@ +import type { Messenger } from '@metamask/messenger'; + +import { ApiPlatformClient } from './api'; +import type { ApiPlatformClientOptions } from './api'; +import type { ApiPlatformClientServiceMethodActions } from './ApiPlatformClientService-method-action-types'; + +// === GENERAL === + +/** + * The name of the {@link ApiPlatformClientService}, used to namespace the + * service's actions and events. + */ +export const apiPlatformClientServiceName = 'ApiPlatformClientService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['getApiPlatformClient'] as const; + +/** + * Actions that {@link ApiPlatformClientService} exposes to other consumers. + */ +export type ApiPlatformClientServiceActions = + ApiPlatformClientServiceMethodActions; + +/** + * Actions from other messengers that {@link ApiPlatformClientServiceMessenger} calls. + */ +type AllowedActions = never; + +/** + * Events that {@link ApiPlatformClientService} exposes to other consumers. + */ +export type ApiPlatformClientServiceEvents = never; + +/** + * Events from other messengers that {@link ApiPlatformClientService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link ApiPlatformClientService}. + */ +export type ApiPlatformClientServiceMessenger = Messenger< + typeof apiPlatformClientServiceName, + ApiPlatformClientServiceActions | AllowedActions, + ApiPlatformClientServiceEvents | AllowedEvents +>; + +// === SERVICE OPTIONS === + +/** + * Options for constructing {@link ApiPlatformClientService}. + */ +export type ApiPlatformClientServiceOptions = { + /** The messenger suited for this service. */ + messenger: ApiPlatformClientServiceMessenger; +} & ApiPlatformClientOptions; + +// === SERVICE DEFINITION === + +/** + * Service that provides access to {@link ApiPlatformClient} via the messenger. + * + * Consumers obtain the client by calling the `ApiPlatformClientService:getApiPlatformClient` + * action, then use it for accounts, prices, token, and tokens API calls. + * + * @example + * + * ```ts + * import { Messenger } from '@metamask/messenger'; + * import { + * ApiPlatformClientService, + * type ApiPlatformClientServiceActions, + * type ApiPlatformClientServiceEvents, + * } from '@metamask/core-backend'; + * + * const rootMessenger = new Messenger<'Root', ApiPlatformClientServiceActions, ApiPlatformClientServiceEvents>({ namespace: 'Root' }); + * const serviceMessenger = new Messenger< + * 'ApiPlatformClientService', + * ApiPlatformClientServiceActions, + * ApiPlatformClientServiceEvents, + * typeof rootMessenger + * >({ namespace: 'ApiPlatformClientService', parent: rootMessenger }); + * + * new ApiPlatformClientService({ + * messenger: serviceMessenger, + * clientProduct: 'metamask-extension', + * getBearerToken: async () => token, + * }); + * + * const client = rootMessenger.call('ApiPlatformClientService:getApiPlatformClient'); + * const balances = await client.accounts.fetchV5MultiAccountBalances(accountIds); + * ``` + */ +export class ApiPlatformClientService { + readonly name: typeof apiPlatformClientServiceName; + + readonly #messenger: ApiPlatformClientServiceMessenger; + + readonly #client: ApiPlatformClient; + + constructor({ + messenger, + ...clientOptions + }: ApiPlatformClientServiceOptions) { + this.name = apiPlatformClientServiceName; + this.#messenger = messenger; + this.#client = new ApiPlatformClient(clientOptions); + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Returns the shared ApiPlatformClient instance. + * + * Use this via the messenger: `messenger.call('ApiPlatformClientService:getApiPlatformClient')`. + * + * @returns The ApiPlatformClient instance (accounts, prices, token, tokens). + */ + getApiPlatformClient(): ApiPlatformClient { + return this.#client; + } +} diff --git a/packages/core-backend/src/api/accounts/client.test.ts b/packages/core-backend/src/api/accounts/client.test.ts index b2a1b0748a6..00d13eac6b7 100644 --- a/packages/core-backend/src/api/accounts/client.test.ts +++ b/packages/core-backend/src/api/accounts/client.test.ts @@ -85,6 +85,13 @@ describe('AccountsApiClient', () => { expect(result).toStrictEqual(mockResponse); }); + + it('returns empty activeNetworks for empty accountIds', async () => { + const result = await client.accounts.fetchV2ActiveNetworks([]); + + expect(result).toStrictEqual({ activeNetworks: [] }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('Balances', () => { @@ -129,6 +136,17 @@ describe('AccountsApiClient', () => { expect(calledUrl).toContain('networks=1%2C137'); }); + it('returns empty balances for empty address', async () => { + const result = await client.accounts.fetchV2Balances(''); + + expect(result).toStrictEqual({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('fetches v5 multi-account balances', async () => { const mockResponse: V5BalancesResponse = { count: 3, @@ -168,7 +186,7 @@ describe('AccountsApiClient', () => { }; mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); - await client.accounts.fetchV2BalancesWithOptions('0x123abc', { + await client.accounts.fetchV2Balances('0x123abc', { networks: [1, 137], filterSupportedTokens: true, includeTokenAddresses: ['0xtoken1', '0xtoken2'], @@ -211,6 +229,28 @@ describe('AccountsApiClient', () => { expect.any(Object), ); }); + + it('returns empty balances for empty accountAddresses', async () => { + const result = await client.accounts.fetchV4MultiAccountBalances([]); + + expect(result).toStrictEqual({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('returns empty balances for empty accountIds in v5', async () => { + const result = await client.accounts.fetchV5MultiAccountBalances([]); + + expect(result).toStrictEqual({ + count: 0, + unprocessedNetworks: [], + balances: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('Transactions', () => { @@ -289,7 +329,6 @@ describe('AccountsApiClient', () => { networks: ['eip155:1'], sortDirection: 'DESC', includeLogs: true, - includeValueTransfers: true, includeTxMetadata: true, }, ); @@ -301,6 +340,39 @@ describe('AccountsApiClient', () => { ); }); + it('returns query options for v4 multi-account transactions usable with fetchQuery', async () => { + const mockResponse = { + unprocessedNetworks: [], + pageInfo: { count: 0, hasNextPage: false }, + data: [], + }; + mockFetch.mockResolvedValueOnce(createMockResponse(mockResponse)); + + const queryOptions = + client.accounts.getV4MultiAccountTransactionsQueryOptions( + ['eip155:1:0x123'], + { sortDirection: 'DESC' }, + ); + + expect(queryOptions).toMatchObject({ + queryKey: [ + 'accounts', + 'transactions', + 'v4MultiAccount', + { + accountAddresses: ['eip155:1:0x123'], + options: { sortDirection: 'DESC' }, + }, + ], + }); + expect(typeof queryOptions.queryFn).toBe('function'); + expect(queryOptions).toHaveProperty('staleTime'); + expect(queryOptions).toHaveProperty('gcTime'); + + const result = await client.queryClient.fetchQuery(queryOptions); + expect(result).toStrictEqual(mockResponse); + }); + it('fetches account transactions with options but no chainIds', async () => { const mockResponse = { data: [], @@ -315,6 +387,16 @@ describe('AccountsApiClient', () => { expect(result).toStrictEqual(mockResponse); }); + + it('returns empty result for empty address without calling fetch', async () => { + const result = await client.accounts.fetchV1AccountTransactions(''); + + expect(result).toStrictEqual({ + data: [], + pageInfo: { count: 0, hasNextPage: false }, + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('Relationships', () => { @@ -385,7 +467,7 @@ describe('AccountsApiClient', () => { ).rejects.toThrow(HttpError); }); - it('returns error object when relationship fetch fails with body error', async () => { + it('throws when relationship fetch fails with body error', async () => { mockFetch.mockResolvedValueOnce( createMockResponse( { @@ -399,18 +481,9 @@ describe('AccountsApiClient', () => { ), ); - const result = await client.accounts.fetchV1AccountRelationship( - 1, - '0x123', - '0x456', - ); - - expect(result).toStrictEqual({ - error: { - code: 'RELATIONSHIP_NOT_FOUND', - message: 'No relationship exists', - }, - }); + await expect( + client.accounts.fetchV1AccountRelationship(1, '0x123', '0x456'), + ).rejects.toThrow(HttpError); }); }); @@ -451,6 +524,16 @@ describe('AccountsApiClient', () => { expect(result).toStrictEqual(mockResponse); }); + + it('returns empty result for empty address without calling fetch', async () => { + const result = await client.accounts.fetchV2AccountNfts(''); + + expect(result).toStrictEqual({ + data: [], + pageInfo: { count: 0, hasNextPage: false }, + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); describe('Token Discovery', () => { @@ -485,5 +568,211 @@ describe('AccountsApiClient', () => { expect(result).toStrictEqual(mockResponse); }); + + it('returns empty result for empty address without calling fetch', async () => { + const result = await client.accounts.fetchV2AccountTokens(''); + + expect(result).toStrictEqual({ data: [] }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('get*QueryOptions with queryOptions branches', () => { + it('getV1AccountTransactionsQueryOptions includes sorted chainIds in queryKey when queryOptions.chainIds provided', () => { + const options = client.accounts.getV1AccountTransactionsQueryOptions( + '0x123', + { + chainIds: ['eip155:137', 'eip155:1'], + cursor: 'c', + sortDirection: 'DESC', + }, + ); + expect(options.queryKey).toStrictEqual([ + 'accounts', + 'transactions', + 'v1Account', + { + address: '0x123', + options: { + chainIds: ['eip155:1', 'eip155:137'], + cursor: 'c', + sortDirection: 'DESC', + }, + }, + ]); + }); + + it('getV2AccountNftsQueryOptions includes sorted networks in queryKey when queryOptions.networks provided', () => { + const options = client.accounts.getV2AccountNftsQueryOptions('0x123', { + networks: [137, 1], + cursor: 'next', + }); + expect(options.queryKey).toStrictEqual([ + 'accounts', + 'v2Nfts', + { + address: '0x123', + options: { + networks: [1, 137], + cursor: 'next', + }, + }, + ]); + }); + + it('getV2AccountTokensQueryOptions includes sorted networks in queryKey when queryOptions.networks provided', () => { + const options = client.accounts.getV2AccountTokensQueryOptions('0x123', { + networks: [56, 1], + }); + expect(options.queryKey).toStrictEqual([ + 'accounts', + 'v2Tokens', + { + address: '0x123', + options: { + networks: [1, 56], + }, + }, + ]); + }); + }); + + describe('get*QueryOptions empty-input short-circuit', () => { + it('getV2ActiveNetworksQueryOptions queryFn returns empty activeNetworks for empty accountIds without calling fetch', async () => { + const options = client.accounts.getV2ActiveNetworksQueryOptions([]); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ activeNetworks: [] }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV4MultiAccountBalancesQueryOptions queryFn returns empty balances for empty accountAddresses without calling fetch', async () => { + const options = client.accounts.getV4MultiAccountBalancesQueryOptions([]); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV5MultiAccountBalancesQueryOptions queryFn returns empty balances for empty accountIds without calling fetch', async () => { + const options = client.accounts.getV5MultiAccountBalancesQueryOptions([]); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ + count: 0, + unprocessedNetworks: [], + balances: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV2BalancesQueryOptions queryFn returns empty balances for empty address without calling fetch', async () => { + const options = client.accounts.getV2BalancesQueryOptions(''); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ + count: 0, + balances: [], + unprocessedNetworks: [], + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV1AccountTransactionsQueryOptions queryFn returns empty result for empty address without calling fetch', async () => { + const options = client.accounts.getV1AccountTransactionsQueryOptions(''); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ + data: [], + pageInfo: { count: 0, hasNextPage: false }, + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV2AccountNftsQueryOptions queryFn returns empty result for empty address without calling fetch', async () => { + const options = client.accounts.getV2AccountNftsQueryOptions(''); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ + data: [], + pageInfo: { count: 0, hasNextPage: false }, + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV2AccountTokensQueryOptions queryFn returns empty result for empty address without calling fetch', async () => { + const options = client.accounts.getV2AccountTokensQueryOptions(''); + const { queryFn } = options; + if (typeof queryFn !== 'function') { + throw new Error('queryFn is required'); + } + const result = await queryFn({ + client: client.queryClient, + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ data: [] }); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core-backend/src/api/accounts/client.ts b/packages/core-backend/src/api/accounts/client.ts index 9882f46929f..166b02acf00 100644 --- a/packages/core-backend/src/api/accounts/client.ts +++ b/packages/core-backend/src/api/accounts/client.ts @@ -11,7 +11,10 @@ * - Token discovery */ -import type { QueryFunctionContext } from '@tanstack/query-core'; +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; import type { V1SupportedNetworksResponse, @@ -27,13 +30,8 @@ import type { V2NftsResponse, V2TokensResponse, } from './types'; -import { - BaseApiClient, - API_URLS, - STALE_TIMES, - GC_TIMES, - HttpError, -} from '../base-client'; +import { BaseApiClient, API_URLS, STALE_TIMES, GC_TIMES } from '../base-client'; +import { getQueryOptionsOverrides } from '../shared-types'; import type { FetchOptions } from '../shared-types'; /** @@ -68,15 +66,15 @@ export class AccountsApiClient extends BaseApiClient { // ========================================================================== /** - * Get list of supported networks (v1 endpoint). + * Returns the TanStack Query options object for v1 supported networks. * * @param options - Fetch options including cache settings. - * @returns The list of supported networks. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1SupportedNetworks( + getV1SupportedNetworksQueryOptions( options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['accounts', 'v1SupportedNetworks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -84,21 +82,36 @@ export class AccountsApiClient extends BaseApiClient { '/v1/supportedNetworks', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; } /** - * Get list of supported networks (v2 endpoint). + * Get list of supported networks (v1 endpoint). * * @param options - Fetch options including cache settings. * @returns The list of supported networks. */ - async fetchV2SupportedNetworks( + async fetchV1SupportedNetworks( options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): Promise { + return this.queryClient.fetchQuery( + this.getV1SupportedNetworksQueryOptions(options), + ); + } + + /** + * Returns the TanStack Query options object for v2 supported networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV2SupportedNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: ['accounts', 'v2SupportedNetworks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -106,9 +119,24 @@ export class AccountsApiClient extends BaseApiClient { '/v2/supportedNetworks', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; + } + + /** + * Get list of supported networks (v2 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The list of supported networks. + */ + async fetchV2SupportedNetworks( + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV2SupportedNetworksQueryOptions(options), + ); } // ========================================================================== @@ -116,21 +144,21 @@ export class AccountsApiClient extends BaseApiClient { // ========================================================================== /** - * Get active networks by CAIP-10 account IDs (v2 endpoint). + * Returns the TanStack Query options object for v2 active networks. * * @param accountIds - Array of CAIP-10 account IDs. * @param queryOptions - Query filter options. * @param queryOptions.filterMMListTokens - Whether to filter MM list tokens. * @param queryOptions.networks - Networks to filter by. * @param options - Fetch options including cache settings. - * @returns The active networks response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV2ActiveNetworks( + getV2ActiveNetworksQueryOptions( accountIds: string[], queryOptions?: { filterMMListTokens?: boolean; networks?: string[] }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'v2ActiveNetworks', @@ -143,8 +171,13 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountIds.length === 0) { + return { activeNetworks: [] }; + } + return this.fetch( API_URLS.ACCOUNTS, '/v2/activeNetworks', { @@ -155,60 +188,43 @@ export class AccountsApiClient extends BaseApiClient { networks: queryOptions?.networks, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } - // ========================================================================== - // BALANCES - // ========================================================================== - /** - * Get account balances for a single address (v2 endpoint). + * Get active networks by CAIP-10 account IDs (v2 endpoint). * - * @param address - The account address. + * @param accountIds - Array of CAIP-10 account IDs. * @param queryOptions - Query filter options. + * @param queryOptions.filterMMListTokens - Whether to filter MM list tokens. * @param queryOptions.networks - Networks to filter by. * @param options - Fetch options including cache settings. - * @returns The account balances response. + * @returns The active networks response. */ - async fetchV2Balances( - address: string, - queryOptions?: { networks?: number[] }, + async fetchV2ActiveNetworks( + accountIds: string[], + queryOptions?: { filterMMListTokens?: boolean; networks?: string[] }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ - queryKey: [ - 'accounts', - 'balances', - 'v2', - { - address, - options: queryOptions && { - ...queryOptions, - networks: - queryOptions.networks && [...queryOptions.networks].sort(), - }, - }, - ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( - API_URLS.ACCOUNTS, - `/v2/accounts/${address}/balances`, - { - signal, - params: { networks: queryOptions?.networks }, - }, - ), - staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, - gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + ): Promise { + if (accountIds.length === 0) { + return { activeNetworks: [] }; + } + return this.queryClient.fetchQuery( + this.getV2ActiveNetworksQueryOptions(accountIds, queryOptions, options), + ); } + // ========================================================================== + // BALANCES + // ========================================================================== + /** - * Get account balances with additional options (v2 endpoint). + * Returns the TanStack Query options object for v2 balances. * * @param address - The account address. * @param queryOptions - Query filter options. @@ -217,9 +233,9 @@ export class AccountsApiClient extends BaseApiClient { * @param queryOptions.includeTokenAddresses - Token addresses to include. * @param queryOptions.includeStakedAssets - Whether to include staked assets. * @param options - Fetch options including cache settings. - * @returns The account balances response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV2BalancesWithOptions( + getV2BalancesQueryOptions( address: string, queryOptions?: { networks?: number[]; @@ -228,8 +244,8 @@ export class AccountsApiClient extends BaseApiClient { includeStakedAssets?: boolean; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'balances', @@ -246,8 +262,13 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { count: 0, balances: [], unprocessedNetworks: [] }; + } + return this.fetch( API_URLS.ACCOUNTS, `/v2/accounts/${address}/balances`, { @@ -259,27 +280,59 @@ export class AccountsApiClient extends BaseApiClient { includeStakedAssets: queryOptions?.includeStakedAssets, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get balances for multiple accounts (v4 endpoint). + * Get account balances for a single address (v2 endpoint). + * + * @param address - The account address. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Networks to filter by. + * @param queryOptions.filterSupportedTokens - Whether to filter supported tokens. + * @param queryOptions.includeTokenAddresses - Token addresses to include. + * @param queryOptions.includeStakedAssets - Whether to include staked assets. + * @param options - Fetch options including cache settings. + * @returns The account balances response. + */ + async fetchV2Balances( + address: string, + queryOptions?: { + networks?: number[]; + filterSupportedTokens?: boolean; + includeTokenAddresses?: string[]; + includeStakedAssets?: boolean; + }, + options?: FetchOptions, + ): Promise { + if (address === '') { + return { count: 0, balances: [], unprocessedNetworks: [] }; + } + return this.queryClient.fetchQuery( + this.getV2BalancesQueryOptions(address, queryOptions, options), + ); + } + + /** + * Returns the TanStack Query options object for v4 multi-account balances. * * @param accountAddresses - Array of account addresses. * @param queryOptions - Query filter options. * @param queryOptions.networks - Networks to filter by. * @param options - Fetch options including cache settings. - * @returns The multi-account balances response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV4MultiAccountBalances( + getV4MultiAccountBalancesQueryOptions( accountAddresses: string[], queryOptions?: { networks?: number[] }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'balances', @@ -293,8 +346,13 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountAddresses.length === 0) { + return { count: 0, balances: [], unprocessedNetworks: [] }; + } + return this.fetch( API_URLS.ACCOUNTS, '/v4/multiaccount/balances', { @@ -304,14 +362,42 @@ export class AccountsApiClient extends BaseApiClient { networks: queryOptions?.networks, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get balances for multiple accounts using CAIP-10 IDs (v5 endpoint). + * Get balances for multiple accounts (v4 endpoint). + * + * @param accountAddresses - Array of account addresses. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Networks to filter by. + * @param options - Fetch options including cache settings. + * @returns The multi-account balances response. + */ + async fetchV4MultiAccountBalances( + accountAddresses: string[], + queryOptions?: { networks?: number[] }, + options?: FetchOptions, + ): Promise { + if (accountAddresses.length === 0) { + return { count: 0, balances: [], unprocessedNetworks: [] }; + } + return this.queryClient.fetchQuery( + this.getV4MultiAccountBalancesQueryOptions( + accountAddresses, + queryOptions, + options, + ), + ); + } + + /** + * Returns the TanStack Query options object for v5 multi-account balances. * * @param accountIds - Array of CAIP-10 account IDs. * @param queryOptions - Query filter options. @@ -319,9 +405,9 @@ export class AccountsApiClient extends BaseApiClient { * @param queryOptions.networks - Networks to filter by. * @param queryOptions.includeStakedAssets - Whether to include staked assets. * @param options - Fetch options including cache settings. - * @returns The multi-account balances response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV5MultiAccountBalances( + getV5MultiAccountBalancesQueryOptions( accountIds: string[], queryOptions?: { filterMMListTokens?: boolean; @@ -329,8 +415,8 @@ export class AccountsApiClient extends BaseApiClient { includeStakedAssets?: boolean; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'balances', @@ -344,8 +430,13 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (accountIds.length === 0) { + return { count: 0, unprocessedNetworks: [], balances: [] }; + } + return this.fetch( API_URLS.ACCOUNTS, '/v5/multiaccount/balances', { @@ -357,10 +448,44 @@ export class AccountsApiClient extends BaseApiClient { includeStakedAssets: queryOptions?.includeStakedAssets, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.BALANCES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get balances for multiple accounts using CAIP-10 IDs (v5 endpoint). + * + * @param accountIds - Array of CAIP-10 account IDs. + * @param queryOptions - Query filter options. + * @param queryOptions.filterMMListTokens - Whether to filter MM list tokens. + * @param queryOptions.networks - Networks to filter by. + * @param queryOptions.includeStakedAssets - Whether to include staked assets. + * @param options - Fetch options including cache settings. + * @returns The multi-account balances response. + */ + async fetchV5MultiAccountBalances( + accountIds: string[], + queryOptions?: { + filterMMListTokens?: boolean; + networks?: string[]; + includeStakedAssets?: boolean; + }, + options?: FetchOptions, + ): Promise { + if (accountIds.length === 0) { + return { count: 0, unprocessedNetworks: [], balances: [] }; + } + return this.queryClient.fetchQuery( + this.getV5MultiAccountBalancesQueryOptions( + accountIds, + queryOptions, + options, + ), + ); } // ========================================================================== @@ -368,7 +493,7 @@ export class AccountsApiClient extends BaseApiClient { // ========================================================================== /** - * Get a specific transaction by hash (v1 endpoint). + * Returns the TanStack Query options object for v1 transaction by hash. * * @param chainId - The chain ID. * @param txHash - The transaction hash. @@ -378,9 +503,9 @@ export class AccountsApiClient extends BaseApiClient { * @param queryOptions.includeTxMetadata - Whether to include transaction metadata. * @param queryOptions.lang - Language for metadata. * @param options - Fetch options including cache settings. - * @returns The transaction details. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1TransactionByHash( + getV1TransactionByHashQueryOptions( chainId: number, txHash: string, queryOptions?: { @@ -390,8 +515,8 @@ export class AccountsApiClient extends BaseApiClient { lang?: string; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'transactions', @@ -412,13 +537,48 @@ export class AccountsApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRANSACTIONS, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get account transactions (v1 endpoint). + * Get a specific transaction by hash (v1 endpoint). + * + * @param chainId - The chain ID. + * @param txHash - The transaction hash. + * @param queryOptions - Query filter options. + * @param queryOptions.includeLogs - Whether to include logs. + * @param queryOptions.includeValueTransfers - Whether to include value transfers. + * @param queryOptions.includeTxMetadata - Whether to include transaction metadata. + * @param queryOptions.lang - Language for metadata. + * @param options - Fetch options including cache settings. + * @returns The transaction details. + */ + async fetchV1TransactionByHash( + chainId: number, + txHash: string, + queryOptions?: { + includeLogs?: boolean; + includeValueTransfers?: boolean; + includeTxMetadata?: boolean; + lang?: string; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1TransactionByHashQueryOptions( + chainId, + txHash, + queryOptions, + options, + ), + ); + } + + /** + * Returns the TanStack Query options object for v1 account transactions. * * @param address - The account address. * @param queryOptions - Query filter options. @@ -428,9 +588,9 @@ export class AccountsApiClient extends BaseApiClient { * @param queryOptions.endTimestamp - End timestamp filter. * @param queryOptions.sortDirection - Sort direction (ASC/DESC). * @param options - Fetch options including cache settings. - * @returns The account transactions response. + * @returns TanStack Query options for use with useQuery, useInfiniteQuery, useSuspenseQuery, etc. */ - async fetchV1AccountTransactions( + getV1AccountTransactionsQueryOptions( address: string, queryOptions?: { chainIds?: string[]; @@ -440,8 +600,8 @@ export class AccountsApiClient extends BaseApiClient { sortDirection?: 'ASC' | 'DESC'; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'transactions', @@ -455,8 +615,13 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { data: [], pageInfo: { count: 0, hasNextPage: false } }; + } + return this.fetch( API_URLS.ACCOUNTS, `/v1/accounts/${address}/transactions`, { @@ -469,45 +634,93 @@ export class AccountsApiClient extends BaseApiClient { sortDirection: queryOptions?.sortDirection, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRANSACTIONS, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get multi-account transactions (v4 endpoint). + * Get account transactions (v1 endpoint). * - * @param accountIds - Array of CAIP-10 account IDs. + * @param address - The account address. * @param queryOptions - Query filter options. - * @param queryOptions.networks - Networks to filter by. + * @param queryOptions.chainIds - Chain IDs to filter by. * @param queryOptions.cursor - Pagination cursor. + * @param queryOptions.startTimestamp - Start timestamp filter. + * @param queryOptions.endTimestamp - End timestamp filter. + * @param queryOptions.sortDirection - Sort direction (ASC/DESC). + * @param options - Fetch options including cache settings. + * @returns The account transactions response. + */ + async fetchV1AccountTransactions( + address: string, + queryOptions?: { + chainIds?: string[]; + cursor?: string; + startTimestamp?: number; + endTimestamp?: number; + sortDirection?: 'ASC' | 'DESC'; + }, + options?: FetchOptions, + ): Promise { + if (address === '') { + return { data: [], pageInfo: { count: 0, hasNextPage: false } }; + } + return this.queryClient.fetchQuery( + this.getV1AccountTransactionsQueryOptions(address, queryOptions, options), + ); + } + + /** + * Returns the TanStack Query options object for v4 multi-account transactions. + * Use this with `queryClient.fetchQuery()`, `useQuery()`, `useInfiniteQuery()`, + * `useSuspenseQuery()`, etc. for flexibility across query permutations. + * + * @param accountAddresses - Array of CAIP-10 account addresses. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Comma-separated CAIP-2 network IDs. + * @param queryOptions.startTimestamp - Start timestamp (epoch) from which to return results. + * @param queryOptions.endTimestamp - End timestamp (epoch) for which to return results. + * @param queryOptions.cursor - Pagination cursor (deprecated, use after). + * @param queryOptions.limit - Maximum number of transactions to request (default 50). + * @param queryOptions.after - JWT containing the endCursor for the query. + * @param queryOptions.before - JWT containing the startCursor for the query. * @param queryOptions.sortDirection - Sort direction (ASC/DESC). * @param queryOptions.includeLogs - Whether to include logs. - * @param queryOptions.includeValueTransfers - Whether to include value transfers. * @param queryOptions.includeTxMetadata - Whether to include transaction metadata. + * @param queryOptions.maxLogsPerTx - Maximum number of logs per transaction. + * @param queryOptions.lang - Language for transaction category (default "en"). * @param options - Fetch options including cache settings. - * @returns The multi-account transactions response. + * @returns Query options object compatible with fetchQuery/useQuery/useInfiniteQuery. */ - async fetchV4MultiAccountTransactions( - accountIds: string[], + getV4MultiAccountTransactionsQueryOptions( + accountAddresses: string[], queryOptions?: { networks?: string[]; + startTimestamp?: number; + endTimestamp?: number; cursor?: string; + limit?: number; + after?: string; + before?: string; sortDirection?: 'ASC' | 'DESC'; includeLogs?: boolean; - includeValueTransfers?: boolean; includeTxMetadata?: boolean; + maxLogsPerTx?: number; + lang?: string; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'transactions', 'v4MultiAccount', { - accountIds: [...accountIds].sort(), + accountAddresses: [...accountAddresses].sort(), options: queryOptions && { ...queryOptions, networks: @@ -522,25 +735,110 @@ export class AccountsApiClient extends BaseApiClient { { signal, params: { - accountIds, + accountAddresses, networks: queryOptions?.networks, + startTimestamp: queryOptions?.startTimestamp, + endTimestamp: queryOptions?.endTimestamp, cursor: queryOptions?.cursor, + limit: queryOptions?.limit, + after: queryOptions?.after, + before: queryOptions?.before, sortDirection: queryOptions?.sortDirection, includeLogs: queryOptions?.includeLogs, - includeValueTransfers: queryOptions?.includeValueTransfers, includeTxMetadata: queryOptions?.includeTxMetadata, + maxLogsPerTx: queryOptions?.maxLogsPerTx, + lang: queryOptions?.lang, }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRANSACTIONS, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get multi-account transactions (v4 endpoint). + * + * @param accountAddresses - Array of CAIP-10 account addresses. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Comma-separated CAIP-2 network IDs. + * @param queryOptions.startTimestamp - Start timestamp (epoch) from which to return results. + * @param queryOptions.endTimestamp - End timestamp (epoch) for which to return results. + * @param queryOptions.cursor - Pagination cursor (deprecated, use after). + * @param queryOptions.limit - Maximum number of transactions to request (default 50). + * @param queryOptions.after - JWT containing the endCursor for the query. + * @param queryOptions.before - JWT containing the startCursor for the query. + * @param queryOptions.sortDirection - Sort direction (ASC/DESC). + * @param queryOptions.includeLogs - Whether to include logs. + * @param queryOptions.includeTxMetadata - Whether to include transaction metadata. + * @param queryOptions.maxLogsPerTx - Maximum number of logs per transaction. + * @param queryOptions.lang - Language for transaction category (default "en"). + * @param options - Fetch options including cache settings. + * @returns The multi-account transactions response. + */ + async fetchV4MultiAccountTransactions( + accountAddresses: string[], + queryOptions?: { + networks?: string[]; + startTimestamp?: number; + endTimestamp?: number; + cursor?: string; + limit?: number; + after?: string; + before?: string; + sortDirection?: 'ASC' | 'DESC'; + includeLogs?: boolean; + includeTxMetadata?: boolean; + maxLogsPerTx?: number; + lang?: string; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV4MultiAccountTransactionsQueryOptions( + accountAddresses, + queryOptions, + options, + ), + ); } // ========================================================================== // RELATIONSHIPS // ========================================================================== + /** + * Returns the TanStack Query options object for v1 account relationship. + * + * @param chainId - The chain ID. + * @param from - The from address. + * @param to - The to address. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1AccountRelationshipQueryOptions( + chainId: number, + from: string, + to: string, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['accounts', 'v1Relationship', chainId, from, to], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => + this.fetch( + API_URLS.ACCOUNTS, + `/v1/networks/${chainId}/accounts/${from}/relationships/${to}`, + { signal }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.DEFAULT, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; + } + /** * Get account address relationship (v1 endpoint). * @@ -556,40 +854,9 @@ export class AccountsApiClient extends BaseApiClient { to: string, options?: FetchOptions, ): Promise { - return this.queryClient.fetchQuery({ - queryKey: ['accounts', 'v1Relationship', chainId, from, to], - queryFn: async ({ signal }: QueryFunctionContext) => { - try { - return await this.fetch( - API_URLS.ACCOUNTS, - `/v1/networks/${chainId}/accounts/${from}/relationships/${to}`, - { signal }, - ); - } catch (error) { - if (error instanceof HttpError && typeof error.body === 'object') { - const body = error.body as { - error?: { code?: string; message?: string }; - } | null; - if ( - body?.error && - typeof body.error === 'object' && - typeof body.error.code === 'string' && - typeof body.error.message === 'string' - ) { - return { - error: { - code: body.error.code, - message: body.error.message, - }, - }; - } - } - throw error; - } - }, - staleTime: options?.staleTime ?? STALE_TIMES.DEFAULT, - gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + return this.queryClient.fetchQuery( + this.getV1AccountRelationshipQueryOptions(chainId, from, to, options), + ); } // ========================================================================== @@ -597,21 +864,21 @@ export class AccountsApiClient extends BaseApiClient { // ========================================================================== /** - * Get NFTs owned by an account (v2 endpoint). + * Returns the TanStack Query options object for v2 account NFTs. * * @param address - The account address. * @param queryOptions - Query filter options. * @param queryOptions.networks - Networks to filter by. * @param queryOptions.cursor - Pagination cursor. * @param options - Fetch options including cache settings. - * @returns The NFTs response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV2AccountNfts( + getV2AccountNftsQueryOptions( address: string, queryOptions?: { networks?: number[]; cursor?: string }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'v2Nfts', @@ -624,8 +891,13 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { data: [], pageInfo: { count: 0, hasNextPage: false } }; + } + return this.fetch( API_URLS.ACCOUNTS, `/v2/accounts/${address}/nfts`, { @@ -635,10 +907,35 @@ export class AccountsApiClient extends BaseApiClient { cursor: queryOptions?.cursor, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.DEFAULT, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get NFTs owned by an account (v2 endpoint). + * + * @param address - The account address. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Networks to filter by. + * @param queryOptions.cursor - Pagination cursor. + * @param options - Fetch options including cache settings. + * @returns The NFTs response. + */ + async fetchV2AccountNfts( + address: string, + queryOptions?: { networks?: number[]; cursor?: string }, + options?: FetchOptions, + ): Promise { + if (address === '') { + return { data: [], pageInfo: { count: 0, hasNextPage: false } }; + } + return this.queryClient.fetchQuery( + this.getV2AccountNftsQueryOptions(address, queryOptions, options), + ); } // ========================================================================== @@ -646,20 +943,20 @@ export class AccountsApiClient extends BaseApiClient { // ========================================================================== /** - * Get ERC20 tokens detected for an account (v2 endpoint). + * Returns the TanStack Query options object for v2 account tokens. * * @param address - The account address. * @param queryOptions - Query filter options. * @param queryOptions.networks - Networks to filter by. * @param options - Fetch options including cache settings. - * @returns The tokens response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV2AccountTokens( + getV2AccountTokensQueryOptions( address: string, queryOptions?: { networks?: number[] }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'accounts', 'v2Tokens', @@ -672,17 +969,46 @@ export class AccountsApiClient extends BaseApiClient { }, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (address === '') { + return { data: [] }; + } + return this.fetch( API_URLS.ACCOUNTS, `/v2/accounts/${address}/tokens`, { signal, params: { networks: queryOptions?.networks }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.DEFAULT, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get ERC20 tokens detected for an account (v2 endpoint). + * + * @param address - The account address. + * @param queryOptions - Query filter options. + * @param queryOptions.networks - Networks to filter by. + * @param options - Fetch options including cache settings. + * @returns The tokens response. + */ + async fetchV2AccountTokens( + address: string, + queryOptions?: { networks?: number[] }, + options?: FetchOptions, + ): Promise { + if (address === '') { + return { data: [] }; + } + return this.queryClient.fetchQuery( + this.getV2AccountTokensQueryOptions(address, queryOptions, options), + ); } } diff --git a/packages/core-backend/src/api/base-client.test.ts b/packages/core-backend/src/api/base-client.test.ts index fdb73c7cba5..3db0eda66e6 100644 --- a/packages/core-backend/src/api/base-client.test.ts +++ b/packages/core-backend/src/api/base-client.test.ts @@ -5,8 +5,29 @@ import { QueryClient } from '@tanstack/query-core'; import { AccountsApiClient } from './accounts'; +import { authQueryKeys } from './base-client'; describe('BaseApiClient', () => { + describe('invalidateAuthToken', () => { + it('calls resetQueries on the query client with auth bearer token key', async () => { + const mockResetQueries = jest.fn().mockResolvedValue(undefined); + const queryClient = { + resetQueries: mockResetQueries, + } as unknown as QueryClient; + const client = new AccountsApiClient({ + clientProduct: 'test-product', + queryClient, + }); + + await client.invalidateAuthToken(); + + expect(mockResetQueries).toHaveBeenCalledTimes(1); + expect(mockResetQueries).toHaveBeenCalledWith({ + queryKey: authQueryKeys.bearerToken(), + }); + }); + }); + describe('QueryClient initialization', () => { it('creates a new QueryClient when none is provided', () => { // Create a client without providing a queryClient diff --git a/packages/core-backend/src/api/index.ts b/packages/core-backend/src/api/index.ts index f0799b8b3d6..c2b57cb0851 100644 --- a/packages/core-backend/src/api/index.ts +++ b/packages/core-backend/src/api/index.ts @@ -17,6 +17,7 @@ export { GC_TIMES, RETRY_CONFIG, calculateRetryDelay, + getQueryOptionsOverrides, shouldRetry, HttpError, } from './shared-types'; diff --git a/packages/core-backend/src/api/prices/client.test.ts b/packages/core-backend/src/api/prices/client.test.ts index c2010b41bf3..0b30e03739e 100644 --- a/packages/core-backend/src/api/prices/client.test.ts +++ b/packages/core-backend/src/api/prices/client.test.ts @@ -2,9 +2,14 @@ * Prices API Client Tests - price.api.cx.metamask.io */ -import type { V1ExchangeRatesResponse, V3SpotPricesResponse } from './types'; +import type { + PriceSupportedNetworksResponse, + V1ExchangeRatesResponse, + V3SpotPricesResponse, +} from './types'; import type { ApiPlatformClient } from '../ApiPlatformClient'; import { API_URLS } from '../shared-types'; +import type { FetchOptions } from '../shared-types'; import { createMockResponse, mockFetch, @@ -74,6 +79,13 @@ describe('PricesApiClient', () => { ); }); + it('returns empty object for empty baseCurrency', async () => { + const result = await client.prices.fetchV1ExchangeRates(''); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('fetches fiat exchange rates', async () => { const mockResponse: V1ExchangeRatesResponse = { USD: { @@ -190,18 +202,14 @@ describe('PricesApiClient', () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it('fetches single token price and returns undefined on error', async () => { + it('fetchV1TokenPrice throws on request error', async () => { mockFetch.mockResolvedValueOnce( createMockResponse({ error: 'Not found' }, 404, 'Not Found'), ); - const result = await client.prices.fetchV1TokenPrice( - '0x1', - '0xtoken', - 'usd', - ); - - expect(result).toBeUndefined(); + await expect( + client.prices.fetchV1TokenPrice('0x1', '0xtoken', 'usd'), + ).rejects.toThrow(Error); }); it('fetches single token price successfully', async () => { @@ -519,4 +527,148 @@ describe('PricesApiClient', () => { expect(calledUrl).toContain('includeOHLC=false'); }); }); + + describe('get*QueryOptions default currency branch', () => { + it('getV1SpotPriceByCoinIdQueryOptions uses default currency usd when not passed', () => { + const options = + client.prices.getV1SpotPriceByCoinIdQueryOptions('ethereum'); + expect(options.queryKey).toStrictEqual([ + 'prices', + 'v1SpotPriceByCoinId', + 'ethereum', + 'usd', + ]); + }); + + it('getV1TokenPriceQueryOptions uses default currency usd when not passed', () => { + const options = client.prices.getV1TokenPriceQueryOptions( + '0x1', + '0xabc123', + ); + expect(options.queryKey).toStrictEqual([ + 'prices', + 'v1TokenPrice', + '0x1', + '0xabc123', + 'usd', + ]); + }); + }); + + describe('get*QueryOptions empty-input short-circuit', () => { + it('getV1SpotPricesByCoinIdsQueryOptions queryFn returns {} for empty coinIds without calling fetch', async () => { + const options = client.prices.getV1SpotPricesByCoinIdsQueryOptions([]); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV1TokenPricesQueryOptions queryFn returns {} for empty tokenAddresses without calling fetch', async () => { + const options = client.prices.getV1TokenPricesQueryOptions('0x1', []); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV2SpotPricesQueryOptions queryFn returns {} for empty tokenAddresses without calling fetch', async () => { + const options = client.prices.getV2SpotPricesQueryOptions('0x1', []); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV3SpotPricesQueryOptions queryFn returns {} for empty assetIds without calling fetch', async () => { + const options = client.prices.getV3SpotPricesQueryOptions([]); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV1ExchangeRatesQueryOptions queryFn returns {} for empty baseCurrency without calling fetch', async () => { + const options = client.prices.getV1ExchangeRatesQueryOptions(''); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV1SpotPriceByCoinIdQueryOptions queryFn returns safe empty result for empty coinId without calling fetch', async () => { + const options = client.prices.getV1SpotPriceByCoinIdQueryOptions(''); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual({ id: '', price: 0 }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('get*QueryOptions pass-through options (select, initialPageParam)', () => { + it('getPriceV1SupportedNetworksQueryOptions merges select and initialPageParam from options', () => { + const select = ( + data: PriceSupportedNetworksResponse, + ): PriceSupportedNetworksResponse => data; + const options = client.prices.getPriceV1SupportedNetworksQueryOptions({ + select, + initialPageParam: 0, + } as unknown as FetchOptions); + expect(options.queryKey).toStrictEqual(['prices', 'v1SupportedNetworks']); + const opts = options as unknown as Record; + expect(opts.select).toBe(select); + expect(opts.initialPageParam).toBe(0); + }); + + it('getPriceV1SupportedNetworksQueryOptions applies staleTime and gcTime from options', () => { + const options = client.prices.getPriceV1SupportedNetworksQueryOptions({ + staleTime: 100, + gcTime: 200, + }); + expect(options.staleTime).toBe(100); + expect(options.gcTime).toBe(200); + }); + }); }); diff --git a/packages/core-backend/src/api/prices/client.ts b/packages/core-backend/src/api/prices/client.ts index 044d18cc41a..9cc92f08f50 100644 --- a/packages/core-backend/src/api/prices/client.ts +++ b/packages/core-backend/src/api/prices/client.ts @@ -9,7 +9,10 @@ * - Price graphs */ -import type { QueryFunctionContext } from '@tanstack/query-core'; +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; import type { CoinGeckoSpotPrice, @@ -20,10 +23,11 @@ import type { V3HistoricalPricesResponse, } from './types'; import { BaseApiClient, API_URLS, STALE_TIMES, GC_TIMES } from '../base-client'; +import { getQueryOptionsOverrides } from '../shared-types'; import type { FetchOptions, - SupportedCurrency, MarketDataDetails, + SupportedCurrency, } from '../shared-types'; /** @@ -49,15 +53,15 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get price supported networks (v1 endpoint). + * Returns the TanStack Query options object for price v1 supported networks. * * @param options - Fetch options including cache settings. - * @returns The supported networks response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchPriceV1SupportedNetworks( + getPriceV1SupportedNetworksQueryOptions( options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['prices', 'v1SupportedNetworks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -65,21 +69,36 @@ export class PricesApiClient extends BaseApiClient { '/v1/supportedNetworks', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; } /** - * Get price supported networks in CAIP format (v2 endpoint). + * Get price supported networks (v1 endpoint). * * @param options - Fetch options including cache settings. * @returns The supported networks response. */ - async fetchPriceV2SupportedNetworks( + async fetchPriceV1SupportedNetworks( options?: FetchOptions, ): Promise { - return this.queryClient.fetchQuery({ + return this.queryClient.fetchQuery( + this.getPriceV1SupportedNetworksQueryOptions(options), + ); + } + + /** + * Returns the TanStack Query options object for price v2 supported networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getPriceV2SupportedNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: ['prices', 'v2SupportedNetworks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -87,9 +106,24 @@ export class PricesApiClient extends BaseApiClient { '/v2/supportedNetworks', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; + } + + /** + * Get price supported networks in CAIP format (v2 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The supported networks response. + */ + async fetchPriceV2SupportedNetworks( + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getPriceV2SupportedNetworksQueryOptions(options), + ); } // ========================================================================== @@ -97,42 +131,68 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get all exchange rates for a base currency (v1 endpoint). + * Returns the TanStack Query options object for v1 exchange rates. * * @param baseCurrency - The base currency code. * @param options - Fetch options including cache settings. - * @returns The exchange rates response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1ExchangeRates( + getV1ExchangeRatesQueryOptions( baseCurrency: string, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['prices', 'v1ExchangeRates', baseCurrency], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (baseCurrency === '') { + return {}; + } + return this.fetch( API_URLS.PRICES, '/v1/exchange-rates', { signal, params: { baseCurrency }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.EXCHANGE_RATES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get fiat exchange rates (v1 endpoint). + * Get all exchange rates for a base currency (v1 endpoint). * + * @param baseCurrency - The base currency code. * @param options - Fetch options including cache settings. * @returns The exchange rates response. */ - async fetchV1FiatExchangeRates( + async fetchV1ExchangeRates( + baseCurrency: string, options?: FetchOptions, ): Promise { - return this.queryClient.fetchQuery({ + if (baseCurrency === '') { + return {}; + } + return this.queryClient.fetchQuery( + this.getV1ExchangeRatesQueryOptions(baseCurrency, options), + ); + } + + /** + * Returns the TanStack Query options object for v1 fiat exchange rates. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1FiatExchangeRatesQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: ['prices', 'v1FiatExchangeRates'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -140,21 +200,36 @@ export class PricesApiClient extends BaseApiClient { '/v1/exchange-rates/fiat', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.EXCHANGE_RATES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get crypto exchange rates (v1 endpoint). + * Get fiat exchange rates (v1 endpoint). * * @param options - Fetch options including cache settings. * @returns The exchange rates response. */ - async fetchV1CryptoExchangeRates( + async fetchV1FiatExchangeRates( options?: FetchOptions, ): Promise { - return this.queryClient.fetchQuery({ + return this.queryClient.fetchQuery( + this.getV1FiatExchangeRatesQueryOptions(options), + ); + } + + /** + * Returns the TanStack Query options object for v1 crypto exchange rates. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1CryptoExchangeRatesQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: ['prices', 'v1CryptoExchangeRates'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -162,9 +237,24 @@ export class PricesApiClient extends BaseApiClient { '/v1/exchange-rates/crypto', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.EXCHANGE_RATES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get crypto exchange rates (v1 endpoint). + * + * @param options - Fetch options including cache settings. + * @returns The exchange rates response. + */ + async fetchV1CryptoExchangeRates( + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1CryptoExchangeRatesQueryOptions(options), + ); } // ========================================================================== @@ -172,66 +262,114 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get spot prices by CoinGecko coin IDs (v1 endpoint). + * Returns the TanStack Query options object for v1 spot prices by coin IDs. * * @param coinIds - Array of CoinGecko coin IDs. * @param options - Fetch options including cache settings. - * @returns The spot prices by coin ID. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1SpotPricesByCoinIds( + getV1SpotPricesByCoinIdsQueryOptions( coinIds: string[], options?: FetchOptions, - ): Promise> { - if (coinIds.length === 0) { - return {}; - } - return this.queryClient.fetchQuery({ + ): FetchQueryOptions> { + return { queryKey: [ 'prices', 'v1SpotPricesByCoinIds', { coinIds: [...coinIds].sort() }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch>( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise> => { + if (coinIds.length === 0) { + return {}; + } + return this.fetch>( API_URLS.PRICES, '/v1/spot-prices', { signal, params: { coinIds }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get spot price for a single CoinGecko coin ID (v1 endpoint). + * Get spot prices by CoinGecko coin IDs (v1 endpoint). + * + * @param coinIds - Array of CoinGecko coin IDs. + * @param options - Fetch options including cache settings. + * @returns The spot prices by coin ID. + */ + async fetchV1SpotPricesByCoinIds( + coinIds: string[], + options?: FetchOptions, + ): Promise> { + if (coinIds.length === 0) { + return {}; + } + return this.queryClient.fetchQuery( + this.getV1SpotPricesByCoinIdsQueryOptions(coinIds, options), + ); + } + + /** + * Returns the TanStack Query options object for v1 spot price by coin ID. * * @param coinId - The CoinGecko coin ID. * @param currency - The currency for prices. * @param options - Fetch options including cache settings. - * @returns The spot price data. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1SpotPriceByCoinId( + getV1SpotPriceByCoinIdQueryOptions( coinId: string, currency: SupportedCurrency = 'usd', options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['prices', 'v1SpotPriceByCoinId', coinId, currency], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (coinId === '') { + return { id: '', price: 0 }; + } + return this.fetch( API_URLS.PRICES, `/v1/spot-prices/${coinId}`, { signal, params: { vsCurrency: currency }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get spot price for a single CoinGecko coin ID (v1 endpoint). + * + * @param coinId - The CoinGecko coin ID. + * @param currency - The currency for prices. + * @param options - Fetch options including cache settings. + * @returns The spot price data. + */ + async fetchV1SpotPriceByCoinId( + coinId: string, + currency: SupportedCurrency = 'usd', + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1SpotPriceByCoinIdQueryOptions(coinId, currency, options), + ); } // ========================================================================== @@ -239,7 +377,7 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get spot prices for tokens on a chain (v1 endpoint). + * Returns the TanStack Query options object for v1 token prices. * * @param chainId - The chain ID (hex format). * @param tokenAddresses - Array of token addresses. @@ -247,9 +385,9 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.currency - The currency for prices. * @param queryOptions.includeMarketData - Whether to include market data. * @param options - Fetch options including cache settings. - * @returns The token prices by address. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1TokenPrices( + getV1TokenPricesQueryOptions( chainId: string, tokenAddresses: string[], queryOptions?: { @@ -257,13 +395,10 @@ export class PricesApiClient extends BaseApiClient { includeMarketData?: boolean; }, options?: FetchOptions, - ): Promise>> { - if (tokenAddresses.length === 0) { - return {}; - } + ): FetchQueryOptions>> { const chainIdDecimal = parseInt(chainId, 16); const currency = queryOptions?.currency ?? 'usd'; - return this.queryClient.fetchQuery({ + return { queryKey: [ 'prices', 'v1TokenPrices', @@ -274,8 +409,15 @@ export class PricesApiClient extends BaseApiClient { includeMarketData: queryOptions?.includeMarketData, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch>>( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise< + Record> + > => { + if (chainId === '' || tokenAddresses.length === 0) { + return {}; + } + return this.fetch>>( API_URLS.PRICES, `/v1/chains/${chainIdDecimal}/spot-prices`, { @@ -286,10 +428,81 @@ export class PricesApiClient extends BaseApiClient { includeMarketData: queryOptions?.includeMarketData, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get spot prices for tokens on a chain (v1 endpoint). + * + * @param chainId - The chain ID (hex format). + * @param tokenAddresses - Array of token addresses. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.includeMarketData - Whether to include market data. + * @param options - Fetch options including cache settings. + * @returns The token prices by address. + */ + async fetchV1TokenPrices( + chainId: string, + tokenAddresses: string[], + queryOptions?: { + currency?: SupportedCurrency; + includeMarketData?: boolean; + }, + options?: FetchOptions, + ): Promise>> { + if (chainId === '' || tokenAddresses.length === 0) { + return {}; + } + return this.queryClient.fetchQuery( + this.getV1TokenPricesQueryOptions( + chainId, + tokenAddresses, + queryOptions, + options, + ), + ); + } + + /** + * Returns the TanStack Query options object for v1 token price. + * + * @param chainId - The chain ID (hex format). + * @param tokenAddress - The token address. + * @param currency - The currency for prices. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1TokenPriceQueryOptions( + chainId: string, + tokenAddress: string, + currency: SupportedCurrency = 'usd', + options?: FetchOptions, + ): FetchQueryOptions { + const chainIdDecimal = parseInt(chainId, 16); + return { + queryKey: ['prices', 'v1TokenPrice', chainId, tokenAddress, currency], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + return this.fetch( + API_URLS.PRICES, + `/v1/chains/${chainIdDecimal}/spot-prices/${tokenAddress}`, + { + signal, + params: { vsCurrency: currency }, + }, + ); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.PRICES, + gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, + }; } /** @@ -299,33 +512,22 @@ export class PricesApiClient extends BaseApiClient { * @param tokenAddress - The token address. * @param currency - The currency for prices. * @param options - Fetch options including cache settings. - * @returns The market data or undefined. + * @returns The market data. */ async fetchV1TokenPrice( chainId: string, tokenAddress: string, currency: SupportedCurrency = 'usd', options?: FetchOptions, - ): Promise { - const chainIdDecimal = parseInt(chainId, 16); - try { - return await this.queryClient.fetchQuery({ - queryKey: ['prices', 'v1TokenPrice', chainId, tokenAddress, currency], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( - API_URLS.PRICES, - `/v1/chains/${chainIdDecimal}/spot-prices/${tokenAddress}`, - { - signal, - params: { vsCurrency: currency }, - }, - ), - staleTime: options?.staleTime ?? STALE_TIMES.PRICES, - gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); - } catch { - return undefined; - } + ): Promise { + return this.queryClient.fetchQuery( + this.getV1TokenPriceQueryOptions( + chainId, + tokenAddress, + currency, + options, + ), + ); } // ========================================================================== @@ -333,7 +535,7 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get spot prices for tokens on a chain with market data (v2 endpoint). + * Returns the TanStack Query options object for v2 spot prices. * * @param chainId - The chain ID (hex format). * @param tokenAddresses - Array of token addresses. @@ -341,9 +543,9 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.currency - The currency for prices. * @param queryOptions.includeMarketData - Whether to include market data. * @param options - Fetch options including cache settings. - * @returns The spot prices with market data. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV2SpotPrices( + getV2SpotPricesQueryOptions( chainId: string, tokenAddresses: string[], queryOptions?: { @@ -351,14 +553,11 @@ export class PricesApiClient extends BaseApiClient { includeMarketData?: boolean; }, options?: FetchOptions, - ): Promise> { - if (tokenAddresses.length === 0) { - return {}; - } + ): FetchQueryOptions> { const chainIdDecimal = parseInt(chainId, 16); const currency = queryOptions?.currency ?? 'usd'; const includeMarketData = queryOptions?.includeMarketData ?? true; - return this.queryClient.fetchQuery({ + return { queryKey: [ 'prices', 'v2SpotPrices', @@ -369,8 +568,13 @@ export class PricesApiClient extends BaseApiClient { includeMarketData, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch>( + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise> => { + if (chainId === '' || tokenAddresses.length === 0) { + return {}; + } + return this.fetch>( API_URLS.PRICES, `/v2/chains/${chainIdDecimal}/spot-prices`, { @@ -381,10 +585,45 @@ export class PricesApiClient extends BaseApiClient { includeMarketData: queryOptions?.includeMarketData ?? true, }, }, - ), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get spot prices for tokens on a chain with market data (v2 endpoint). + * + * @param chainId - The chain ID (hex format). + * @param tokenAddresses - Array of token addresses. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.includeMarketData - Whether to include market data. + * @param options - Fetch options including cache settings. + * @returns The spot prices with market data. + */ + async fetchV2SpotPrices( + chainId: string, + tokenAddresses: string[], + queryOptions?: { + currency?: SupportedCurrency; + includeMarketData?: boolean; + }, + options?: FetchOptions, + ): Promise> { + if (chainId === '' || tokenAddresses.length === 0) { + return {}; + } + return this.queryClient.fetchQuery( + this.getV2SpotPricesQueryOptions( + chainId, + tokenAddresses, + queryOptions, + options, + ), + ); } // ========================================================================== @@ -392,7 +631,7 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get spot prices by CAIP-19 asset IDs (v3 endpoint). + * Returns the TanStack Query options object for v3 spot prices. * * @param assetIds - Array of CAIP-19 asset IDs. * @param queryOptions - Query options. @@ -400,9 +639,9 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.includeMarketData - Whether to include market data. * @param queryOptions.cacheOnly - Whether to use cache only. * @param options - Fetch options including cache settings. - * @returns The spot prices response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV3SpotPrices( + getV3SpotPricesQueryOptions( assetIds: string[], queryOptions?: { currency?: SupportedCurrency; @@ -410,14 +649,11 @@ export class PricesApiClient extends BaseApiClient { cacheOnly?: boolean; }, options?: FetchOptions, - ): Promise { - if (assetIds.length === 0) { - return {}; - } + ): FetchQueryOptions { const currency = queryOptions?.currency ?? 'usd'; const includeMarketData = queryOptions?.includeMarketData ?? true; const cacheOnly = queryOptions?.cacheOnly ?? false; - return this.queryClient.fetchQuery({ + return { queryKey: [ 'prices', 'v3SpotPrices', @@ -428,19 +664,58 @@ export class PricesApiClient extends BaseApiClient { cacheOnly, }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch(API_URLS.PRICES, '/v3/spot-prices', { - signal, - params: { - assetIds, - vsCurrency: currency, - includeMarketData, - cacheOnly, + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (assetIds.length === 0) { + return {}; + } + return this.fetch( + API_URLS.PRICES, + '/v3/spot-prices', + { + signal, + params: { + assetIds, + vsCurrency: currency, + includeMarketData, + cacheOnly, + }, }, - }), + ); + }, + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get spot prices by CAIP-19 asset IDs (v3 endpoint). + * + * @param assetIds - Array of CAIP-19 asset IDs. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.includeMarketData - Whether to include market data. + * @param queryOptions.cacheOnly - Whether to use cache only. + * @param options - Fetch options including cache settings. + * @returns The spot prices response. + */ + async fetchV3SpotPrices( + assetIds: string[], + queryOptions?: { + currency?: SupportedCurrency; + includeMarketData?: boolean; + cacheOnly?: boolean; + }, + options?: FetchOptions, + ): Promise { + if (assetIds.length === 0) { + return {}; + } + return this.queryClient.fetchQuery( + this.getV3SpotPricesQueryOptions(assetIds, queryOptions, options), + ); } // ========================================================================== @@ -448,7 +723,7 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get historical prices by CoinGecko coin ID (v1 endpoint). + * Returns the TanStack Query options object for v1 historical prices by coin ID. * * @param coinId - The CoinGecko coin ID. * @param queryOptions - Query options. @@ -457,9 +732,9 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.from - Start timestamp. * @param queryOptions.to - End timestamp. * @param options - Fetch options including cache settings. - * @returns The historical prices response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1HistoricalPricesByCoinId( + getV1HistoricalPricesByCoinIdQueryOptions( coinId: string, queryOptions?: { currency?: SupportedCurrency; @@ -468,8 +743,8 @@ export class PricesApiClient extends BaseApiClient { to?: number; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['prices', 'v1HistoricalByCoinId', coinId, queryOptions], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -485,13 +760,45 @@ export class PricesApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get historical prices for tokens on a chain (v1 endpoint). + * Get historical prices by CoinGecko coin ID (v1 endpoint). + * + * @param coinId - The CoinGecko coin ID. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.timePeriod - The time period. + * @param queryOptions.from - Start timestamp. + * @param queryOptions.to - End timestamp. + * @param options - Fetch options including cache settings. + * @returns The historical prices response. + */ + async fetchV1HistoricalPricesByCoinId( + coinId: string, + queryOptions?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1HistoricalPricesByCoinIdQueryOptions( + coinId, + queryOptions, + options, + ), + ); + } + + /** + * Returns the TanStack Query options object for v1 historical prices by token addresses. * * @param chainId - The chain ID (hex format). * @param tokenAddresses - Array of token addresses. @@ -501,9 +808,9 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.from - Start timestamp. * @param queryOptions.to - End timestamp. * @param options - Fetch options including cache settings. - * @returns The historical prices response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1HistoricalPricesByTokenAddresses( + getV1HistoricalPricesByTokenAddressesQueryOptions( chainId: string, tokenAddresses: string[], queryOptions?: { @@ -513,9 +820,9 @@ export class PricesApiClient extends BaseApiClient { to?: number; }, options?: FetchOptions, - ): Promise { + ): FetchQueryOptions { const chainIdDecimal = parseInt(chainId, 16); - return this.queryClient.fetchQuery({ + return { queryKey: [ 'prices', 'v1HistoricalByTokenAddresses', @@ -540,13 +847,48 @@ export class PricesApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get historical prices for a single token (v1 endpoint). + * Get historical prices for tokens on a chain (v1 endpoint). + * + * @param chainId - The chain ID (hex format). + * @param tokenAddresses - Array of token addresses. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.timePeriod - The time period. + * @param queryOptions.from - Start timestamp. + * @param queryOptions.to - End timestamp. + * @param options - Fetch options including cache settings. + * @returns The historical prices response. + */ + async fetchV1HistoricalPricesByTokenAddresses( + chainId: string, + tokenAddresses: string[], + queryOptions?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1HistoricalPricesByTokenAddressesQueryOptions( + chainId, + tokenAddresses, + queryOptions, + options, + ), + ); + } + + /** + * Returns the TanStack Query options object for v1 historical prices. * * @param chainId - The chain ID (hex format). * @param tokenAddress - The token address. @@ -554,18 +896,18 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.currency - The currency for prices. * @param queryOptions.timeRange - The time range. * @param options - Fetch options including cache settings. - * @returns The historical prices response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1HistoricalPrices( + getV1HistoricalPricesQueryOptions( chainId: string, tokenAddress: string, queryOptions?: { currency?: SupportedCurrency; timeRange?: string }, options?: FetchOptions, - ): Promise { + ): FetchQueryOptions { const chainIdDecimal = parseInt(chainId, 16); const currency = queryOptions?.currency ?? 'usd'; const timeRange = queryOptions?.timeRange ?? '7d'; - return this.queryClient.fetchQuery({ + return { queryKey: [ 'prices', 'v1Historical', @@ -586,9 +928,37 @@ export class PricesApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get historical prices for a single token (v1 endpoint). + * + * @param chainId - The chain ID (hex format). + * @param tokenAddress - The token address. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.timeRange - The time range. + * @param options - Fetch options including cache settings. + * @returns The historical prices response. + */ + async fetchV1HistoricalPrices( + chainId: string, + tokenAddress: string, + queryOptions?: { currency?: SupportedCurrency; timeRange?: string }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1HistoricalPricesQueryOptions( + chainId, + tokenAddress, + queryOptions, + options, + ), + ); } // ========================================================================== @@ -596,7 +966,7 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get historical prices by CAIP-19 asset ID (v3 endpoint). + * Returns the TanStack Query options object for v3 historical prices. * * @param chainId - The CAIP-2 chain ID. * @param assetType - The asset type portion of CAIP-19. @@ -607,9 +977,9 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.to - End timestamp. * @param queryOptions.interval - Data interval. * @param options - Fetch options including cache settings. - * @returns The historical prices response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV3HistoricalPrices( + getV3HistoricalPricesQueryOptions( chainId: string, assetType: string, queryOptions?: { @@ -620,8 +990,8 @@ export class PricesApiClient extends BaseApiClient { interval?: '5m' | 'hourly' | 'daily'; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['prices', 'v3Historical', chainId, assetType, queryOptions], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -638,9 +1008,46 @@ export class PricesApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get historical prices by CAIP-19 asset ID (v3 endpoint). + * + * @param chainId - The CAIP-2 chain ID. + * @param assetType - The asset type portion of CAIP-19. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.timePeriod - The time period. + * @param queryOptions.from - Start timestamp. + * @param queryOptions.to - End timestamp. + * @param queryOptions.interval - Data interval. + * @param options - Fetch options including cache settings. + * @returns The historical prices response. + */ + async fetchV3HistoricalPrices( + chainId: string, + assetType: string, + queryOptions?: { + currency?: SupportedCurrency; + timePeriod?: string; + from?: number; + to?: number; + interval?: '5m' | 'hourly' | 'daily'; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV3HistoricalPricesQueryOptions( + chainId, + assetType, + queryOptions, + options, + ), + ); } // ========================================================================== @@ -648,23 +1055,23 @@ export class PricesApiClient extends BaseApiClient { // ========================================================================== /** - * Get historical price graph data by CoinGecko coin ID (v1 endpoint). + * Returns the TanStack Query options object for v1 historical price graph by coin ID. * * @param coinId - The CoinGecko coin ID. * @param queryOptions - Query options. * @param queryOptions.currency - The currency for prices. * @param queryOptions.includeOHLC - Whether to include OHLC data. * @param options - Fetch options including cache settings. - * @returns The historical price graph response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1HistoricalPriceGraphByCoinId( + getV1HistoricalPriceGraphByCoinIdQueryOptions( coinId: string, queryOptions?: { currency?: SupportedCurrency; includeOHLC?: boolean }, options?: FetchOptions, - ): Promise { + ): FetchQueryOptions { const currency = queryOptions?.currency ?? 'usd'; const includeOHLC = queryOptions?.includeOHLC ?? false; - return this.queryClient.fetchQuery({ + return { queryKey: ['prices', 'v1GraphByCoinId', coinId, currency, includeOHLC], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -678,13 +1085,38 @@ export class PricesApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; } /** - * Get historical price graph data by token address (v1 endpoint). + * Get historical price graph data by CoinGecko coin ID (v1 endpoint). + * + * @param coinId - The CoinGecko coin ID. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.includeOHLC - Whether to include OHLC data. + * @param options - Fetch options including cache settings. + * @returns The historical price graph response. + */ + async fetchV1HistoricalPriceGraphByCoinId( + coinId: string, + queryOptions?: { currency?: SupportedCurrency; includeOHLC?: boolean }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1HistoricalPriceGraphByCoinIdQueryOptions( + coinId, + queryOptions, + options, + ), + ); + } + + /** + * Returns the TanStack Query options object for v1 historical price graph by token address. * * @param chainId - The chain ID (hex format). * @param tokenAddress - The token address. @@ -692,18 +1124,18 @@ export class PricesApiClient extends BaseApiClient { * @param queryOptions.currency - The currency for prices. * @param queryOptions.includeOHLC - Whether to include OHLC data. * @param options - Fetch options including cache settings. - * @returns The historical price graph response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1HistoricalPriceGraphByTokenAddress( + getV1HistoricalPriceGraphByTokenAddressQueryOptions( chainId: string, tokenAddress: string, queryOptions?: { currency?: SupportedCurrency; includeOHLC?: boolean }, options?: FetchOptions, - ): Promise { + ): FetchQueryOptions { const chainIdDecimal = parseInt(chainId, 16); const currency = queryOptions?.currency ?? 'usd'; const includeOHLC = queryOptions?.includeOHLC ?? false; - return this.queryClient.fetchQuery({ + return { queryKey: [ 'prices', 'v1GraphByTokenAddress', @@ -724,8 +1156,36 @@ export class PricesApiClient extends BaseApiClient { }, }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.PRICES, gcTime: options?.gcTime ?? GC_TIMES.DEFAULT, - }); + }; + } + + /** + * Get historical price graph data by token address (v1 endpoint). + * + * @param chainId - The chain ID (hex format). + * @param tokenAddress - The token address. + * @param queryOptions - Query options. + * @param queryOptions.currency - The currency for prices. + * @param queryOptions.includeOHLC - Whether to include OHLC data. + * @param options - Fetch options including cache settings. + * @returns The historical price graph response. + */ + async fetchV1HistoricalPriceGraphByTokenAddress( + chainId: string, + tokenAddress: string, + queryOptions?: { currency?: SupportedCurrency; includeOHLC?: boolean }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1HistoricalPriceGraphByTokenAddressQueryOptions( + chainId, + tokenAddress, + queryOptions, + options, + ), + ); } } diff --git a/packages/core-backend/src/api/shared-types.ts b/packages/core-backend/src/api/shared-types.ts index 4e503f904c7..72ea886b1a5 100644 --- a/packages/core-backend/src/api/shared-types.ts +++ b/packages/core-backend/src/api/shared-types.ts @@ -2,7 +2,7 @@ * Shared types, constants, and utilities for the API Platform Client. */ -import type { QueryClient } from '@tanstack/query-core'; +import type { FetchQueryOptions, QueryClient } from '@tanstack/query-core'; // ============================================================================ // SHARED TYPES @@ -140,12 +140,53 @@ export type ApiPlatformClientOptions = { queryClient?: QueryClient; }; +/** + * Options for API fetch and query methods. + * Extends TanStack Query options (e.g. select, initialPageParam, retry) so callers + * can pass them through to useQuery / useInfiniteQuery. queryKey and queryFn are + * always set by the client and cannot be overridden. + * staleTime and gcTime are explicitly number (not function) so that client defaults apply without type conflicts. + */ export type FetchOptions = { - /** Custom stale time (ms) */ + /** Custom stale time (ms). */ staleTime?: number; - /** Custom GC time (ms) */ + /** Custom GC time (ms). */ gcTime?: number; -}; +} & Partial< + Omit< + FetchQueryOptions, + 'queryKey' | 'queryFn' | 'staleTime' | 'gcTime' + > +> & { + /** Allowed for infinite query options (e.g. useInfiniteQuery). */ + initialPageParam?: unknown; + }; + +/** + * Returns options with queryKey and queryFn omitted, for merging into + * get*QueryOptions return values without overwriting the client's queryKey/queryFn. + * Return type is intentionally loose so that spreading into FetchQueryOptions + * does not conflict with T-specific option types (e.g. select, staleTime). + * + * @param options - Optional FetchOptions from the caller. + * @returns Options safe to spread into query options, or undefined. + */ +export function getQueryOptionsOverrides( + options?: FetchOptions, +): Record | undefined { + if (options === null || options === undefined) { + return undefined; + } + const { + queryKey: _qk, + queryFn: _qf, + ...rest + } = options as FetchOptions & { + queryKey?: unknown; + queryFn?: unknown; + }; + return rest as Record; +} // ============================================================================ // CONSTANTS diff --git a/packages/core-backend/src/api/token/client.ts b/packages/core-backend/src/api/token/client.ts index 2bb9ebced0b..19ca58da27d 100644 --- a/packages/core-backend/src/api/token/client.ts +++ b/packages/core-backend/src/api/token/client.ts @@ -12,7 +12,10 @@ * - Occurrence floors */ -import type { QueryFunctionContext } from '@tanstack/query-core'; +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; import type { TokenMetadata, @@ -25,6 +28,7 @@ import type { V1SuggestedOccurrenceFloorsResponse, } from './types'; import { BaseApiClient, API_URLS, STALE_TIMES, GC_TIMES } from '../base-client'; +import { getQueryOptionsOverrides } from '../shared-types'; import type { FetchOptions } from '../shared-types'; /** @@ -52,47 +56,132 @@ export class TokenApiClient extends BaseApiClient { // ========================================================================== /** - * Get all networks. + * Returns the TanStack Query options object for networks. * * @param options - Fetch options including cache settings. - * @returns Array of network info. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchNetworks(options?: FetchOptions): Promise { - return this.queryClient.fetchQuery({ + getNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: ['token', 'networks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch(API_URLS.TOKEN, '/networks', { signal }), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; } /** - * Get network by chain ID. + * Get all networks. + * + * @param options - Fetch options including cache settings. + * @returns Array of network info. + */ + async fetchNetworks(options?: FetchOptions): Promise { + return this.queryClient.fetchQuery(this.getNetworksQueryOptions(options)); + } + + /** + * Returns the TanStack Query options object for network by chain ID. * * @param chainId - The chain ID. * @param options - Fetch options including cache settings. - * @returns The network info. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchNetworkByChainId( + getNetworkByChainIdQueryOptions( chainId: number, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['token', 'networkByChainId', chainId], queryFn: ({ signal }: QueryFunctionContext) => this.fetch(API_URLS.TOKEN, `/networks/${chainId}`, { signal, }), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; + } + + /** + * Get network by chain ID. + * + * @param chainId - The chain ID. + * @param options - Fetch options including cache settings. + * @returns The network info. + */ + async fetchNetworkByChainId( + chainId: number, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getNetworkByChainIdQueryOptions(chainId, options), + ); } // ========================================================================== // TOKEN LIST // ========================================================================== + /** + * Returns the TanStack Query options object for token list. + * + * @param chainId - The chain ID. + * @param queryOptions - Query options. + * @param queryOptions.includeTokenFees - Whether to include token fees. + * @param queryOptions.includeAssetType - Whether to include asset type. + * @param queryOptions.includeAggregators - Whether to include aggregators. + * @param queryOptions.includeERC20Permit - Whether to include ERC20 permit. + * @param queryOptions.includeOccurrences - Whether to include occurrences. + * @param queryOptions.includeStorage - Whether to include storage. + * @param queryOptions.includeIconUrl - Whether to include icon URL. + * @param queryOptions.includeAddress - Whether to include address. + * @param queryOptions.includeName - Whether to include name. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getTokenListQueryOptions( + chainId: number, + queryOptions?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['token', 'tokenList', { chainId, options: queryOptions }], + queryFn: ({ signal }: QueryFunctionContext) => + this.fetch(API_URLS.TOKEN, `/tokens/${chainId}`, { + signal, + params: { + includeTokenFees: queryOptions?.includeTokenFees, + includeAssetType: queryOptions?.includeAssetType, + includeAggregators: queryOptions?.includeAggregators, + includeERC20Permit: queryOptions?.includeERC20Permit, + includeOccurrences: queryOptions?.includeOccurrences, + includeStorage: queryOptions?.includeStorage, + includeIconUrl: queryOptions?.includeIconUrl, + includeAddress: queryOptions?.includeAddress, + includeName: queryOptions?.includeName, + }, + }), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TOKEN_LIST, + gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, + }; + } + /** * Get token list for a chain. * @@ -125,12 +214,62 @@ export class TokenApiClient extends BaseApiClient { }, options?: FetchOptions, ): Promise { - return this.queryClient.fetchQuery({ - queryKey: ['token', 'tokenList', { chainId, options: queryOptions }], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch(API_URLS.TOKEN, `/tokens/${chainId}`, { + return this.queryClient.fetchQuery( + this.getTokenListQueryOptions(chainId, queryOptions, options), + ); + } + + // ========================================================================== + // TOKEN METADATA + // ========================================================================== + + /** + * Returns the TanStack Query options object for v1 token metadata. + * + * @param chainId - The chain ID. + * @param tokenAddress - The token address. + * @param queryOptions - Query options. + * @param queryOptions.includeTokenFees - Whether to include token fees. + * @param queryOptions.includeAssetType - Whether to include asset type. + * @param queryOptions.includeAggregators - Whether to include aggregators. + * @param queryOptions.includeERC20Permit - Whether to include ERC20 permit. + * @param queryOptions.includeOccurrences - Whether to include occurrences. + * @param queryOptions.includeStorage - Whether to include storage. + * @param queryOptions.includeIconUrl - Whether to include icon URL. + * @param queryOptions.includeAddress - Whether to include address. + * @param queryOptions.includeName - Whether to include name. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV1TokenMetadataQueryOptions( + chainId: number, + tokenAddress: string, + queryOptions?: { + includeTokenFees?: boolean; + includeAssetType?: boolean; + includeAggregators?: boolean; + includeERC20Permit?: boolean; + includeOccurrences?: boolean; + includeStorage?: boolean; + includeIconUrl?: boolean; + includeAddress?: boolean; + includeName?: boolean; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: [ + 'token', + 'v1Metadata', + { chainId, tokenAddress, options: queryOptions }, + ], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + return this.fetch(API_URLS.TOKEN, `/token/${chainId}`, { signal, params: { + address: tokenAddress, includeTokenFees: queryOptions?.includeTokenFees, includeAssetType: queryOptions?.includeAssetType, includeAggregators: queryOptions?.includeAggregators, @@ -141,16 +280,14 @@ export class TokenApiClient extends BaseApiClient { includeAddress: queryOptions?.includeAddress, includeName: queryOptions?.includeName, }, - }), - staleTime: options?.staleTime ?? STALE_TIMES.TOKEN_LIST, + }); + }, + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TOKEN_METADATA, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; } - // ========================================================================== - // TOKEN METADATA - // ========================================================================== - /** * Get token metadata by address. * @@ -186,36 +323,51 @@ export class TokenApiClient extends BaseApiClient { options?: FetchOptions, ): Promise { try { - return await this.queryClient.fetchQuery({ - queryKey: [ - 'token', - 'v1Metadata', - { chainId, tokenAddress, options: queryOptions }, - ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch(API_URLS.TOKEN, `/token/${chainId}`, { - signal, - params: { - address: tokenAddress, - includeTokenFees: queryOptions?.includeTokenFees, - includeAssetType: queryOptions?.includeAssetType, - includeAggregators: queryOptions?.includeAggregators, - includeERC20Permit: queryOptions?.includeERC20Permit, - includeOccurrences: queryOptions?.includeOccurrences, - includeStorage: queryOptions?.includeStorage, - includeIconUrl: queryOptions?.includeIconUrl, - includeAddress: queryOptions?.includeAddress, - includeName: queryOptions?.includeName, - }, - }), - staleTime: options?.staleTime ?? STALE_TIMES.TOKEN_METADATA, - gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + return await this.queryClient.fetchQuery( + this.getV1TokenMetadataQueryOptions( + chainId, + tokenAddress, + queryOptions, + options, + ), + ); } catch { return undefined; } } + /** + * Returns the TanStack Query options object for token description. + * + * @param chainId - The chain ID. + * @param tokenAddress - The token address. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getTokenDescriptionQueryOptions( + chainId: number, + tokenAddress: string, + options?: FetchOptions, + ): FetchQueryOptions { + return { + queryKey: ['token', 'tokenDescription', chainId, tokenAddress], + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => + this.fetch( + API_URLS.TOKEN, + `/token/${chainId}/description`, + { + signal, + params: { address: tokenAddress }, + }, + ), + ...getQueryOptionsOverrides(options), + staleTime: options?.staleTime ?? STALE_TIMES.TOKEN_METADATA, + gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, + }; + } + /** * Get token description. * @@ -230,20 +382,9 @@ export class TokenApiClient extends BaseApiClient { options?: FetchOptions, ): Promise { try { - return await this.queryClient.fetchQuery({ - queryKey: ['token', 'tokenDescription', chainId, tokenAddress], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch( - API_URLS.TOKEN, - `/token/${chainId}/description`, - { - signal, - params: { address: tokenAddress }, - }, - ), - staleTime: options?.staleTime ?? STALE_TIMES.TOKEN_METADATA, - gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + return await this.queryClient.fetchQuery( + this.getTokenDescriptionQueryOptions(chainId, tokenAddress, options), + ); } catch { return undefined; } @@ -254,7 +395,7 @@ export class TokenApiClient extends BaseApiClient { // ========================================================================== /** - * Get trending tokens (v3 endpoint). + * Returns the TanStack Query options object for v3 trending tokens. * * @param chainIds - Array of chain IDs. * @param queryOptions - Query options. @@ -265,9 +406,9 @@ export class TokenApiClient extends BaseApiClient { * @param queryOptions.minMarketCap - Minimum market cap filter. * @param queryOptions.maxMarketCap - Maximum market cap filter. * @param options - Fetch options including cache settings. - * @returns Array of trending tokens. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV3TrendingTokens( + getV3TrendingTokensQueryOptions( chainIds: string[], queryOptions?: { sortBy?: TrendingSortOption; @@ -278,8 +419,8 @@ export class TokenApiClient extends BaseApiClient { maxMarketCap?: number; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'token', 'v3Trending', @@ -298,13 +439,45 @@ export class TokenApiClient extends BaseApiClient { maxMarketCap: queryOptions?.maxMarketCap, }, }), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRENDING, gcTime: options?.gcTime ?? GC_TIMES.SHORT, - }); + }; } /** - * Get top gainers/losers (v3 endpoint). + * Get trending tokens (v3 endpoint). + * + * @param chainIds - Array of chain IDs. + * @param queryOptions - Query options. + * @param queryOptions.sortBy - Sort option. + * @param queryOptions.minLiquidity - Minimum liquidity filter. + * @param queryOptions.minVolume24hUsd - Minimum 24h volume filter. + * @param queryOptions.maxVolume24hUsd - Maximum 24h volume filter. + * @param queryOptions.minMarketCap - Minimum market cap filter. + * @param queryOptions.maxMarketCap - Maximum market cap filter. + * @param options - Fetch options including cache settings. + * @returns Array of trending tokens. + */ + async fetchV3TrendingTokens( + chainIds: string[], + queryOptions?: { + sortBy?: TrendingSortOption; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV3TrendingTokensQueryOptions(chainIds, queryOptions, options), + ); + } + + /** + * Returns the TanStack Query options object for v3 top gainers. * * @param chainIds - Array of chain IDs. * @param queryOptions - Query options. @@ -316,9 +489,9 @@ export class TokenApiClient extends BaseApiClient { * @param queryOptions.minMarketCap - Minimum market cap filter. * @param queryOptions.maxMarketCap - Maximum market cap filter. * @param options - Fetch options including cache settings. - * @returns Array of top gainer tokens. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV3TopGainers( + getV3TopGainersQueryOptions( chainIds: string[], queryOptions?: { sort?: TopGainersSortOption; @@ -330,8 +503,8 @@ export class TokenApiClient extends BaseApiClient { maxMarketCap?: number; }, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'token', 'v3TopGainers', @@ -351,16 +524,18 @@ export class TokenApiClient extends BaseApiClient { maxMarketCap: queryOptions?.maxMarketCap, }, }), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRENDING, gcTime: options?.gcTime ?? GC_TIMES.SHORT, - }); + }; } /** - * Get popular tokens (v3 endpoint). + * Get top gainers/losers (v3 endpoint). * * @param chainIds - Array of chain IDs. * @param queryOptions - Query options. + * @param queryOptions.sort - Sort option. * @param queryOptions.blockRegion - Region filter (global/us). * @param queryOptions.minLiquidity - Minimum liquidity filter. * @param queryOptions.minVolume24hUsd - Minimum 24h volume filter. @@ -368,11 +543,12 @@ export class TokenApiClient extends BaseApiClient { * @param queryOptions.minMarketCap - Minimum market cap filter. * @param queryOptions.maxMarketCap - Maximum market cap filter. * @param options - Fetch options including cache settings. - * @returns Array of popular tokens. + * @returns Array of top gainer tokens. */ - async fetchV3PopularTokens( + async fetchV3TopGainers( chainIds: string[], queryOptions?: { + sort?: TopGainersSortOption; blockRegion?: 'global' | 'us'; minLiquidity?: number; minVolume24hUsd?: number; @@ -382,7 +558,38 @@ export class TokenApiClient extends BaseApiClient { }, options?: FetchOptions, ): Promise { - return this.queryClient.fetchQuery({ + return this.queryClient.fetchQuery( + this.getV3TopGainersQueryOptions(chainIds, queryOptions, options), + ); + } + + /** + * Returns the TanStack Query options object for v3 popular tokens. + * + * @param chainIds - Array of chain IDs. + * @param queryOptions - Query options. + * @param queryOptions.blockRegion - Region filter (global/us). + * @param queryOptions.minLiquidity - Minimum liquidity filter. + * @param queryOptions.minVolume24hUsd - Minimum 24h volume filter. + * @param queryOptions.maxVolume24hUsd - Maximum 24h volume filter. + * @param queryOptions.minMarketCap - Minimum market cap filter. + * @param queryOptions.maxMarketCap - Maximum market cap filter. + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getV3PopularTokensQueryOptions( + chainIds: string[], + queryOptions?: { + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: [ 'token', 'v3Popular', @@ -401,9 +608,41 @@ export class TokenApiClient extends BaseApiClient { maxMarketCap: queryOptions?.maxMarketCap, }, }), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRENDING, gcTime: options?.gcTime ?? GC_TIMES.SHORT, - }); + }; + } + + /** + * Get popular tokens (v3 endpoint). + * + * @param chainIds - Array of chain IDs. + * @param queryOptions - Query options. + * @param queryOptions.blockRegion - Region filter (global/us). + * @param queryOptions.minLiquidity - Minimum liquidity filter. + * @param queryOptions.minVolume24hUsd - Minimum 24h volume filter. + * @param queryOptions.maxVolume24hUsd - Maximum 24h volume filter. + * @param queryOptions.minMarketCap - Minimum market cap filter. + * @param queryOptions.maxMarketCap - Maximum market cap filter. + * @param options - Fetch options including cache settings. + * @returns Array of popular tokens. + */ + async fetchV3PopularTokens( + chainIds: string[], + queryOptions?: { + blockRegion?: 'global' | 'us'; + minLiquidity?: number; + minVolume24hUsd?: number; + maxVolume24hUsd?: number; + minMarketCap?: number; + maxMarketCap?: number; + }, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV3PopularTokensQueryOptions(chainIds, queryOptions, options), + ); } // ========================================================================== @@ -411,25 +650,42 @@ export class TokenApiClient extends BaseApiClient { // ========================================================================== /** - * Get top assets for a chain. + * Returns the TanStack Query options object for top assets. * * @param chainId - The chain ID. * @param options - Fetch options including cache settings. - * @returns Array of top assets. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchTopAssets( + getTopAssetsQueryOptions( chainId: number, options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['token', 'topAssets', chainId], queryFn: ({ signal }: QueryFunctionContext) => this.fetch(API_URLS.TOKEN, `/topAssets/${chainId}`, { signal, }), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.TRENDING, gcTime: options?.gcTime ?? GC_TIMES.SHORT, - }); + }; + } + + /** + * Get top assets for a chain. + * + * @param chainId - The chain ID. + * @param options - Fetch options including cache settings. + * @returns Array of top assets. + */ + async fetchTopAssets( + chainId: number, + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getTopAssetsQueryOptions(chainId, options), + ); } // ========================================================================== @@ -437,15 +693,15 @@ export class TokenApiClient extends BaseApiClient { // ========================================================================== /** - * Get suggested occurrence floors for all chains. + * Returns the TanStack Query options object for v1 suggested occurrence floors. * * @param options - Fetch options including cache settings. - * @returns The suggested occurrence floors response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV1SuggestedOccurrenceFloors( + getV1SuggestedOccurrenceFloorsQueryOptions( options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['token', 'v1SuggestedOccurrenceFloors'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -453,8 +709,23 @@ export class TokenApiClient extends BaseApiClient { '/v1/suggestedOccurrenceFloors', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; + } + + /** + * Get suggested occurrence floors for all chains. + * + * @param options - Fetch options including cache settings. + * @returns The suggested occurrence floors response. + */ + async fetchV1SuggestedOccurrenceFloors( + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getV1SuggestedOccurrenceFloorsQueryOptions(options), + ); } } diff --git a/packages/core-backend/src/api/tokens/client.test.ts b/packages/core-backend/src/api/tokens/client.test.ts index 73fb7b2eb7a..a0559a44129 100644 --- a/packages/core-backend/src/api/tokens/client.test.ts +++ b/packages/core-backend/src/api/tokens/client.test.ts @@ -106,5 +106,27 @@ describe('TokensApiClient', () => { expect.any(Object), ); }); + + it('returns empty array for empty assetIds', async () => { + const result = await client.tokens.fetchV3Assets([]); + + expect(result).toStrictEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('getV3AssetsQueryOptions queryFn returns [] for empty assetIds without calling fetch', async () => { + const options = client.tokens.getV3AssetsQueryOptions([]); + if (!options.queryFn) { + throw new Error('queryFn is required'); + } + const result = await options.queryFn({ + queryKey: options.queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + expect(result).toStrictEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core-backend/src/api/tokens/client.ts b/packages/core-backend/src/api/tokens/client.ts index f0eb242f8ad..69e5e1ad7ea 100644 --- a/packages/core-backend/src/api/tokens/client.ts +++ b/packages/core-backend/src/api/tokens/client.ts @@ -6,7 +6,10 @@ * - V3 Assets */ -import type { QueryFunctionContext } from '@tanstack/query-core'; +import type { + FetchQueryOptions, + QueryFunctionContext, +} from '@tanstack/query-core'; import type { V1TokenSupportedNetworksResponse, @@ -15,6 +18,7 @@ import type { V3AssetsQueryOptions, } from './types'; import { BaseApiClient, API_URLS, STALE_TIMES, GC_TIMES } from '../base-client'; +import { getQueryOptionsOverrides } from '../shared-types'; import type { FetchOptions } from '../shared-types'; /** @@ -40,15 +44,15 @@ export class TokensApiClient extends BaseApiClient { // ========================================================================== /** - * Get token supported networks (v1 endpoint). + * Returns the TanStack Query options object for token v1 supported networks. * * @param options - Fetch options including cache settings. - * @returns The supported networks response. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchTokenV1SupportedNetworks( + getTokenV1SupportedNetworksQueryOptions( options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: ['tokens', 'v1SupportedNetworks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -56,22 +60,36 @@ export class TokensApiClient extends BaseApiClient { '/v1/supportedNetworks', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; } /** - * Get token supported networks (v2 endpoint). - * Returns both fullSupport and partialSupport networks. + * Get token supported networks (v1 endpoint). * * @param options - Fetch options including cache settings. * @returns The supported networks response. */ - async fetchTokenV2SupportedNetworks( + async fetchTokenV1SupportedNetworks( options?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): Promise { + return this.queryClient.fetchQuery( + this.getTokenV1SupportedNetworksQueryOptions(options), + ); + } + + /** + * Returns the TanStack Query options object for token v2 supported networks. + * + * @param options - Fetch options including cache settings. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. + */ + getTokenV2SupportedNetworksQueryOptions( + options?: FetchOptions, + ): FetchQueryOptions { + return { queryKey: ['tokens', 'v2SupportedNetworks'], queryFn: ({ signal }: QueryFunctionContext) => this.fetch( @@ -79,9 +97,25 @@ export class TokensApiClient extends BaseApiClient { '/v2/supportedNetworks', { signal }, ), + ...getQueryOptionsOverrides(options), staleTime: options?.staleTime ?? STALE_TIMES.SUPPORTED_NETWORKS, gcTime: options?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; + } + + /** + * Get token supported networks (v2 endpoint). + * Returns both fullSupport and partialSupport networks. + * + * @param options - Fetch options including cache settings. + * @returns The supported networks response. + */ + async fetchTokenV2SupportedNetworks( + options?: FetchOptions, + ): Promise { + return this.queryClient.fetchQuery( + this.getTokenV2SupportedNetworksQueryOptions(options), + ); } // ========================================================================== @@ -89,34 +123,62 @@ export class TokensApiClient extends BaseApiClient { // ========================================================================== /** - * Fetch assets by IDs (v3) with caching. + * Returns the TanStack Query options object for v3 assets. * * @param assetIds - Array of CAIP-19 asset IDs. - * @param queryOptions - Query options to include additional data in response. + * @param queryOptions - API query options (filters, etc.). * @param fetchOptions - Fetch options including cache settings. - * @returns Array of asset responses. + * @returns TanStack Query options for use with useQuery, useSuspenseQuery, etc. */ - async fetchV3Assets( + getV3AssetsQueryOptions( assetIds: string[], queryOptions?: V3AssetsQueryOptions, fetchOptions?: FetchOptions, - ): Promise { - return this.queryClient.fetchQuery({ + ): FetchQueryOptions { + return { queryKey: [ 'tokens', 'v3Assets', { assetIds: [...assetIds].sort(), ...queryOptions }, ], - queryFn: ({ signal }: QueryFunctionContext) => - this.fetch(API_URLS.TOKENS, '/v3/assets', { + queryFn: async ({ + signal, + }: QueryFunctionContext): Promise => { + if (assetIds.length === 0) { + return []; + } + return this.fetch(API_URLS.TOKENS, '/v3/assets', { signal, params: { assetIds, ...queryOptions, }, - }), + }); + }, + ...getQueryOptionsOverrides(fetchOptions), staleTime: fetchOptions?.staleTime ?? STALE_TIMES.TOKEN_METADATA, gcTime: fetchOptions?.gcTime ?? GC_TIMES.EXTENDED, - }); + }; + } + + /** + * Fetch assets by IDs (v3) with caching. + * + * @param assetIds - Array of CAIP-19 asset IDs. + * @param queryOptions - Query options to include additional data in response. + * @param fetchOptions - Fetch options including cache settings. + * @returns Array of asset responses. + */ + async fetchV3Assets( + assetIds: string[], + queryOptions?: V3AssetsQueryOptions, + fetchOptions?: FetchOptions, + ): Promise { + if (assetIds.length === 0) { + return []; + } + return this.queryClient.fetchQuery( + this.getV3AssetsQueryOptions(assetIds, queryOptions, fetchOptions), + ); } } diff --git a/packages/core-backend/src/index.ts b/packages/core-backend/src/index.ts index d64d11cbe54..82451571184 100644 --- a/packages/core-backend/src/index.ts +++ b/packages/core-backend/src/index.ts @@ -64,6 +64,22 @@ export type { AccountActivityMessage, } from './types'; +// ============================================================================ +// API PLATFORM CLIENT SERVICE +// ============================================================================ + +export { + ApiPlatformClientService, + apiPlatformClientServiceName, +} from './ApiPlatformClientService'; + +export type { + ApiPlatformClientServiceOptions, + ApiPlatformClientServiceActions, + ApiPlatformClientServiceEvents, + ApiPlatformClientServiceMessenger, +} from './ApiPlatformClientService'; + // ============================================================================ // API PLATFORM CLIENT // ============================================================================ @@ -82,6 +98,7 @@ export { GC_TIMES, // Helpers calculateRetryDelay, + getQueryOptionsOverrides, shouldRetry, // Errors HttpError, diff --git a/packages/delegation-controller/CHANGELOG.md b/packages/delegation-controller/CHANGELOG.md index a6accbe6a99..c991eee5a39 100644 --- a/packages/delegation-controller/CHANGELOG.md +++ b/packages/delegation-controller/CHANGELOG.md @@ -7,12 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.1] + ### Changed - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) -- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642), [#7713](https://github.com/MetaMask/core/pull/7713)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7604](https://github.com/MetaMask/core/pull/7604), [#7642](https://github.com/MetaMask/core/pull/7642), [#7713](https://github.com/MetaMask/core/pull/7713)), ([#7897](https://github.com/MetaMask/core/pull/7897)) - The dependencies moved are: - - `@metamask/accounts-controller` (^35.0.2) + - `@metamask/accounts-controller` (^36.0.0) - `@metamask/keyring-controller` (^25.1.0) - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. @@ -100,7 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5592](https://github.com/MetaMask/core/pull/5592)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@2.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@2.0.1...HEAD +[2.0.1]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@2.0.0...@metamask/delegation-controller@2.0.1 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@1.0.0...@metamask/delegation-controller@2.0.0 [1.0.0]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.8.1...@metamask/delegation-controller@1.0.0 [0.8.1]: https://github.com/MetaMask/core/compare/@metamask/delegation-controller@0.8.0...@metamask/delegation-controller@0.8.1 diff --git a/packages/delegation-controller/package.json b/packages/delegation-controller/package.json index 7dfee75fabe..577738f96b9 100644 --- a/packages/delegation-controller/package.json +++ b/packages/delegation-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/delegation-controller", - "version": "2.0.0", + "version": "2.0.1", "description": "Manages delegations for MetaMask", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/keyring-controller": "^25.1.0", "@metamask/messenger": "^0.3.0", @@ -57,11 +57,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/delegation-controller/src/DelegationController.test.ts b/packages/delegation-controller/src/DelegationController.test.ts index c3b808da425..764a0140ef1 100644 --- a/packages/delegation-controller/src/DelegationController.test.ts +++ b/packages/delegation-controller/src/DelegationController.test.ts @@ -126,7 +126,9 @@ function createMessengerMock() { * @returns The mock hash of the delegation (not real hash) */ function hashDelegationMock(delegation: Delegation): Hex { - return `0x${delegation.delegator.slice(2)}${delegation.delegate.slice(2)}${delegation.authority.slice(2)}${delegation.salt.slice(2)}`; + return `0x${delegation.delegator.slice(2)}${delegation.delegate.slice( + 2, + )}${delegation.authority.slice(2)}${delegation.salt.slice(2)}`; } /** @@ -701,7 +703,7 @@ describe(`${controllerName}`, () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -713,7 +715,7 @@ describe(`${controllerName}`, () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -726,8 +728,8 @@ describe(`${controllerName}`, () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "delegations": Object {}, + { + "delegations": {}, } `); }); @@ -741,7 +743,7 @@ describe(`${controllerName}`, () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 0cfc7edcd80..c733c006621 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) -- Bump `@metamask/account-tree-controller` from `^4.0.0` to `^4.1.0` ([#7869](https://github.com/MetaMask/core/pull/7869)) +- Bump `@metamask/account-tree-controller` from `^4.0.0` to `^4.1.1` ([#7869](https://github.com/MetaMask/core/pull/7869)), ([#7897](https://github.com/MetaMask/core/pull/7897)) ## [11.1.0] diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index c49c01a06a4..4b1752dd38d 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -50,7 +50,7 @@ "dependencies": { "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/account-tree-controller": "^4.1.0", + "@metamask/account-tree-controller": "^4.1.1", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/keyring-api": "^21.5.0", @@ -61,13 +61,13 @@ }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/earn-controller/src/EarnController.test.ts b/packages/earn-controller/src/EarnController.test.ts index 8403e0e98b1..649bc6dea4d 100644 --- a/packages/earn-controller/src/EarnController.test.ts +++ b/packages/earn-controller/src/EarnController.test.ts @@ -2677,7 +2677,7 @@ describe('EarnController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "lastUpdated": 0, } `); @@ -2717,51 +2717,51 @@ describe('EarnController', () => { }); expect(derivedTronStaking).toBeNull(); expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(` - Object { + { "lastUpdated": 0, - "lending": Object { + "lending": { "isEligible": true, - "markets": Array [ - Object { + "markets": [ + { "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "chainId": 42161, "id": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "name": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "netSupplyRate": 1.52269127978874, - "outputToken": Object { + "outputToken": { "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "chainId": 42161, }, "protocol": "aave", - "rewards": Array [], + "rewards": [], "totalSupplyRate": 1.52269127978874, "tvlUnderlying": "132942564710249273623333", - "underlying": Object { + "underlying": { "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", "chainId": 42161, }, }, ], - "positions": Array [ - Object { + "positions": [ + { "assets": "112", "chainId": 42161, "id": "0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0", - "market": Object { + "market": { "address": "0x078f358208685046a11c85e8ad32895ded33a249", "chainId": 42161, "id": "0x078f358208685046a11c85e8ad32895ded33a249", "name": "0x078f358208685046a11c85e8ad32895ded33a249", "netSupplyRate": 0.0062858302613958, - "outputToken": Object { + "outputToken": { "address": "0x078f358208685046a11c85e8ad32895ded33a249", "chainId": 42161, }, "protocol": "aave", - "rewards": Array [], + "rewards": [], "totalSupplyRate": 0.0062858302613958, "tvlUnderlying": "315871357755", - "underlying": Object { + "underlying": { "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", "chainId": 42161, }, @@ -2810,50 +2810,50 @@ describe('EarnController', () => { }); expect(derivedTronStaking).toBeNull(); expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(` - Object { - "lending": Object { + { + "lending": { "isEligible": true, - "markets": Array [ - Object { + "markets": [ + { "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "chainId": 42161, "id": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "name": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "netSupplyRate": 1.52269127978874, - "outputToken": Object { + "outputToken": { "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "chainId": 42161, }, "protocol": "aave", - "rewards": Array [], + "rewards": [], "totalSupplyRate": 1.52269127978874, "tvlUnderlying": "132942564710249273623333", - "underlying": Object { + "underlying": { "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", "chainId": 42161, }, }, ], - "positions": Array [ - Object { + "positions": [ + { "assets": "112", "chainId": 42161, "id": "0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0", - "market": Object { + "market": { "address": "0x078f358208685046a11c85e8ad32895ded33a249", "chainId": 42161, "id": "0x078f358208685046a11c85e8ad32895ded33a249", "name": "0x078f358208685046a11c85e8ad32895ded33a249", "netSupplyRate": 0.0062858302613958, - "outputToken": Object { + "outputToken": { "address": "0x078f358208685046a11c85e8ad32895ded33a249", "chainId": 42161, }, "protocol": "aave", - "rewards": Array [], + "rewards": [], "totalSupplyRate": 0.0062858302613958, "tvlUnderlying": "315871357755", - "underlying": Object { + "underlying": { "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", "chainId": 42161, }, @@ -2902,50 +2902,50 @@ describe('EarnController', () => { }); expect(derivedTronStaking).toBeNull(); expect(derivedStateWithoutPooledStaking).toMatchInlineSnapshot(` - Object { - "lending": Object { + { + "lending": { "isEligible": true, - "markets": Array [ - Object { + "markets": [ + { "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "chainId": 42161, "id": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "name": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "netSupplyRate": 1.52269127978874, - "outputToken": Object { + "outputToken": { "address": "0xe50fa9b3c56ffb159cb0fca61f5c9d750e8128c8", "chainId": 42161, }, "protocol": "aave", - "rewards": Array [], + "rewards": [], "totalSupplyRate": 1.52269127978874, "tvlUnderlying": "132942564710249273623333", - "underlying": Object { + "underlying": { "address": "0x82af49447d8a07e3bd95bd0d56f35241523fbab1", "chainId": 42161, }, }, ], - "positions": Array [ - Object { + "positions": [ + { "assets": "112", "chainId": 42161, "id": "0xe6a7d2b7de29167ae4c3864ac0873e6dcd9cb47b-0x078f358208685046a11c85e8ad32895ded33a249-COLLATERAL-0", - "market": Object { + "market": { "address": "0x078f358208685046a11c85e8ad32895ded33a249", "chainId": 42161, "id": "0x078f358208685046a11c85e8ad32895ded33a249", "name": "0x078f358208685046a11c85e8ad32895ded33a249", "netSupplyRate": 0.0062858302613958, - "outputToken": Object { + "outputToken": { "address": "0x078f358208685046a11c85e8ad32895ded33a249", "chainId": 42161, }, "protocol": "aave", - "rewards": Array [], + "rewards": [], "totalSupplyRate": 0.0062858302613958, "tvlUnderlying": "315871357755", - "underlying": Object { + "underlying": { "address": "0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f", "chainId": 42161, }, diff --git a/packages/eip-5792-middleware/CHANGELOG.md b/packages/eip-5792-middleware/CHANGELOG.md index 4d370285017..b69b1d3e8f0 100644 --- a/packages/eip-5792-middleware/CHANGELOG.md +++ b/packages/eip-5792-middleware/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Pass `requiredAssets` from `wallet_sendCalls` to `addTransaction` and `addTransactionBatch` ([#7819](https://github.com/MetaMask/core/pull/7819)) +- Bump `@metamask/transaction-controller` from `62.16.0` to `62.17.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) ### Changed diff --git a/packages/eip-5792-middleware/package.json b/packages/eip-5792-middleware/package.json index c3d457349cd..58400201b13 100644 --- a/packages/eip-5792-middleware/package.json +++ b/packages/eip-5792-middleware/package.json @@ -50,7 +50,7 @@ "dependencies": { "@metamask/messenger": "^0.3.0", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "lodash": "^4.17.21", "uuid": "^8.3.2" @@ -60,12 +60,12 @@ "@metamask/keyring-controller": "^25.1.0", "@metamask/rpc-errors": "^7.0.2", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "klona": "^2.0.6", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/eip-5792-middleware/src/index.test.ts b/packages/eip-5792-middleware/src/index.test.ts index 26d540a8759..6b99a379ce6 100644 --- a/packages/eip-5792-middleware/src/index.test.ts +++ b/packages/eip-5792-middleware/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/eip-5792-middleware', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "processSendCalls", "getCallsStatus", "getCapabilities", diff --git a/packages/eip-7702-internal-rpc-middleware/package.json b/packages/eip-7702-internal-rpc-middleware/package.json index 2ace055aed5..27ae8ec95d8 100644 --- a/packages/eip-7702-internal-rpc-middleware/package.json +++ b/packages/eip-7702-internal-rpc-middleware/package.json @@ -56,11 +56,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/eip1193-permission-middleware/package.json b/packages/eip1193-permission-middleware/package.json index 19103f6b2c9..6fda1e6ec16 100644 --- a/packages/eip1193-permission-middleware/package.json +++ b/packages/eip1193-permission-middleware/package.json @@ -59,11 +59,11 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/rpc-errors": "^7.0.2", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/eip1193-permission-middleware/src/index.test.ts b/packages/eip1193-permission-middleware/src/index.test.ts index 43c8abc3ac9..63fa4531016 100644 --- a/packages/eip1193-permission-middleware/src/index.test.ts +++ b/packages/eip1193-permission-middleware/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/eip1193-permission-middleware', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "getPermissionsHandler", "requestPermissionsHandler", "revokePermissionsHandler", diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index ad7167f3f6c..27c78d87458 100644 --- a/packages/ens-controller/package.json +++ b/packages/ens-controller/package.json @@ -59,11 +59,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/ens-controller/src/EnsController.test.ts b/packages/ens-controller/src/EnsController.test.ts index 66d7464a3c7..b2d2697875e 100644 --- a/packages/ens-controller/src/EnsController.test.ts +++ b/packages/ens-controller/src/EnsController.test.ts @@ -729,7 +729,7 @@ describe('EnsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -746,52 +746,52 @@ describe('EnsController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "ensEntries": Object { - "0x1": Object { - ".": Object { + { + "ensEntries": { + "0x1": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x1", "ensName": ".", }, }, - "0x3": Object { - ".": Object { + "0x3": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x3", "ensName": ".", }, }, - "0x4": Object { - ".": Object { + "0x4": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x4", "ensName": ".", }, }, - "0x4268": Object { - ".": Object { + "0x4268": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x4268", "ensName": ".", }, }, - "0x5": Object { - ".": Object { + "0x5": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x5", "ensName": ".", }, }, - "0xaa36a7": Object { - ".": Object { + "0xaa36a7": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0xaa36a7", "ensName": ".", }, }, }, - "ensResolutionsByAddress": Object {}, + "ensResolutionsByAddress": {}, } `); }); @@ -810,52 +810,52 @@ describe('EnsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "ensEntries": Object { - "0x1": Object { - ".": Object { + { + "ensEntries": { + "0x1": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x1", "ensName": ".", }, }, - "0x3": Object { - ".": Object { + "0x3": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x3", "ensName": ".", }, }, - "0x4": Object { - ".": Object { + "0x4": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x4", "ensName": ".", }, }, - "0x4268": Object { - ".": Object { + "0x4268": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x4268", "ensName": ".", }, }, - "0x5": Object { - ".": Object { + "0x5": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x5", "ensName": ".", }, }, - "0xaa36a7": Object { - ".": Object { + "0xaa36a7": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0xaa36a7", "ensName": ".", }, }, }, - "ensResolutionsByAddress": Object {}, + "ensResolutionsByAddress": {}, } `); }); @@ -874,52 +874,52 @@ describe('EnsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "ensEntries": Object { - "0x1": Object { - ".": Object { + { + "ensEntries": { + "0x1": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x1", "ensName": ".", }, }, - "0x3": Object { - ".": Object { + "0x3": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x3", "ensName": ".", }, }, - "0x4": Object { - ".": Object { + "0x4": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x4", "ensName": ".", }, }, - "0x4268": Object { - ".": Object { + "0x4268": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x4268", "ensName": ".", }, }, - "0x5": Object { - ".": Object { + "0x5": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0x5", "ensName": ".", }, }, - "0xaa36a7": Object { - ".": Object { + "0xaa36a7": { + ".": { "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", "chainId": "0xaa36a7", "ensName": ".", }, }, }, - "ensResolutionsByAddress": Object {}, + "ensResolutionsByAddress": {}, } `); }); diff --git a/packages/error-reporting-service/package.json b/packages/error-reporting-service/package.json index 071befcddc5..ee43634d9a9 100644 --- a/packages/error-reporting-service/package.json +++ b/packages/error-reporting-service/package.json @@ -55,11 +55,11 @@ "@metamask/auto-changelog": "^3.4.4", "@sentry/core": "^9.22.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/eth-block-tracker/package.json b/packages/eth-block-tracker/package.json index 4eaa210abdf..559b12a6816 100644 --- a/packages/eth-block-tracker/package.json +++ b/packages/eth-block-tracker/package.json @@ -64,12 +64,12 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/json-rpc-engine": "^10.2.2", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/json-rpc-random-id": "^1.0.1", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/eth-json-rpc-middleware/package.json b/packages/eth-json-rpc-middleware/package.json index d69b7f58d50..61b1a91137a 100644 --- a/packages/eth-json-rpc-middleware/package.json +++ b/packages/eth-json-rpc-middleware/package.json @@ -72,13 +72,13 @@ "@metamask/network-controller": "^29.0.0", "@ts-bridge/cli": "^0.6.4", "@types/deep-freeze-strict": "^1.1.0", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/pify": "^5.0.2", "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "tsd": "^0.31.2", - "typedoc": "^0.24.8", + "typedoc": "^0.25.13", "typescript": "~5.3.3" }, "engines": { diff --git a/packages/eth-json-rpc-middleware/src/index.test.ts b/packages/eth-json-rpc-middleware/src/index.test.ts index 8dac22d37d4..ff6ab443529 100644 --- a/packages/eth-json-rpc-middleware/src/index.test.ts +++ b/packages/eth-json-rpc-middleware/src/index.test.ts @@ -3,7 +3,7 @@ import * as indexModule from '.'; describe('index module', () => { it('has expected JavaScript exports', () => { expect(indexModule).toMatchInlineSnapshot(` - Object { + { "GetGrantedExecutionPermissionsResultStruct": Struct { "coercer": [Function], "entries": [Function], @@ -12,7 +12,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "chainId": Struct { "coercer": [Function], "entries": [Function], @@ -45,7 +45,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "factory": Struct { "coercer": [Function], "entries": [Function], @@ -81,7 +81,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "data": Struct { "coercer": [Function], "entries": [Function], @@ -137,7 +137,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "chainId": Struct { "coercer": [Function], "entries": [Function], @@ -170,7 +170,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "factory": Struct { "coercer": [Function], "entries": [Function], @@ -206,7 +206,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "data": Struct { "coercer": [Function], "entries": [Function], @@ -251,7 +251,7 @@ describe('index module', () => { "coercer": [Function], "entries": [Function], "refiner": [Function], - "schema": Object { + "schema": { "chainIds": Struct { "coercer": [Function], "entries": [Function], diff --git a/packages/eth-json-rpc-middleware/src/utils/cache.test.ts b/packages/eth-json-rpc-middleware/src/utils/cache.test.ts index 699b7ca5fe5..d755a82ce26 100644 --- a/packages/eth-json-rpc-middleware/src/utils/cache.test.ts +++ b/packages/eth-json-rpc-middleware/src/utils/cache.test.ts @@ -87,7 +87,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByHash:[\\"0x0000000000000000000000000000000000000000\\"]"`, + `"eth_getBlockByHash:["0x0000000000000000000000000000000000000000"]"`, ); }); @@ -100,7 +100,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByNumber:[\\"latest\\"]"`, + `"eth_getBlockByNumber:["latest"]"`, ); }); @@ -113,7 +113,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByNumber:[\\"latest\\",true]"`, + `"eth_getBlockByNumber:["latest",true]"`, ); }); @@ -129,7 +129,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBalance:[\\"0x0000000000000000000000000000000000000000\\"]"`, + `"eth_getBalance:["0x0000000000000000000000000000000000000000"]"`, ); }); @@ -142,7 +142,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getCode:[\\"0x0000000000000000000000000000000000000000\\"]"`, + `"eth_getCode:["0x0000000000000000000000000000000000000000"]"`, ); }); }); @@ -158,7 +158,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByHash:{\\"hash\\":\\"0x0000000000000000000000000000000000000000\\"}"`, + `"eth_getBlockByHash:{"hash":"0x0000000000000000000000000000000000000000"}"`, ); }); @@ -172,7 +172,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByNumber:{\\"block\\":\\"latest\\"}"`, + `"eth_getBlockByNumber:{"block":"latest"}"`, ); }); @@ -186,7 +186,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByNumber:{\\"block\\":\\"latest\\",\\"showTransactionDetails\\":true}"`, + `"eth_getBlockByNumber:{"block":"latest","showTransactionDetails":true}"`, ); }); @@ -203,7 +203,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getBalance:{\\"address\\":\\"0x0000000000000000000000000000000000000000\\",\\"block\\":\\"latest\\"}"`, + `"eth_getBalance:{"address":"0x0000000000000000000000000000000000000000","block":"latest"}"`, ); }); @@ -217,7 +217,7 @@ describe('cache utils', () => { }); expect(identifier).toMatchInlineSnapshot( - `"eth_getCode:{\\"data\\":\\"0x0000000000000000000000000000000000000000\\"}"`, + `"eth_getCode:{"data":"0x0000000000000000000000000000000000000000"}"`, ); }); }); @@ -264,7 +264,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByHash:[\\"0x0000000000000000000000000000000000000000\\"]"`, + `"eth_getBlockByHash:["0x0000000000000000000000000000000000000000"]"`, ); }); @@ -310,7 +310,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBalance:[\\"0x0000000000000000000000000000000000000000\\"]"`, + `"eth_getBalance:["0x0000000000000000000000000000000000000000"]"`, ); }); @@ -326,7 +326,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getCode:[\\"0x0000000000000000000000000000000000000000\\"]"`, + `"eth_getCode:["0x0000000000000000000000000000000000000000"]"`, ); }); }); @@ -345,7 +345,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByHash:{\\"hash\\":\\"0x0000000000000000000000000000000000000000\\"}"`, + `"eth_getBlockByHash:{"hash":"0x0000000000000000000000000000000000000000"}"`, ); }); @@ -362,7 +362,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByNumber:{\\"block\\":\\"latest\\"}"`, + `"eth_getBlockByNumber:{"block":"latest"}"`, ); }); @@ -379,7 +379,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBlockByNumber:{\\"block\\":\\"latest\\",\\"showTransactionDetails\\":true}"`, + `"eth_getBlockByNumber:{"block":"latest","showTransactionDetails":true}"`, ); }); @@ -399,7 +399,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getBalance:{\\"address\\":\\"0x0000000000000000000000000000000000000000\\",\\"block\\":\\"latest\\"}"`, + `"eth_getBalance:{"address":"0x0000000000000000000000000000000000000000","block":"latest"}"`, ); }); @@ -416,7 +416,7 @@ describe('cache utils', () => { ); expect(identifier).toMatchInlineSnapshot( - `"eth_getCode:{\\"data\\":\\"0x0000000000000000000000000000000000000000\\"}"`, + `"eth_getCode:{"data":"0x0000000000000000000000000000000000000000"}"`, ); }); }); @@ -515,7 +515,7 @@ describe('cache utils', () => { }, {}); expect(blockTagIndexes).toMatchInlineSnapshot(` - Object { + { "eth_blockNumber": undefined, "eth_call": 1, "eth_compileLLL": undefined, @@ -571,7 +571,7 @@ describe('cache utils', () => { ); expect(cacheTypes).toMatchInlineSnapshot(` - Object { + { "eth_blockNumber": "block", "eth_call": "block", "eth_compileLLL": "perma", diff --git a/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts b/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts index ee6c5b9430d..64f03ebcfda 100644 --- a/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts +++ b/packages/eth-json-rpc-middleware/test/setupAfterEnv.ts @@ -3,11 +3,6 @@ const UNRESOLVED = Symbol('timedOut'); const originalSetTimeout = global.setTimeout; const TIME_TO_WAIT_UNTIL_UNRESOLVED = 100; -// Workaround for Jest 28 bug with Node >= 19 -Object.defineProperty(global, 'performance', { - writable: true, -}); - /** * Produces a sort of dummy promise which can be used in conjunction with a * "real" promise to determine whether the "real" promise was ever resolved. If diff --git a/packages/eth-json-rpc-provider/package.json b/packages/eth-json-rpc-provider/package.json index 4a0bea30c40..3b6d5834fc4 100644 --- a/packages/eth-json-rpc-provider/package.json +++ b/packages/eth-json-rpc-provider/package.json @@ -64,13 +64,13 @@ "@metamask/eth-query": "^4.0.0", "@metamask/ethjs-query": "^0.5.3", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "ethers": "^6.12.0", - "jest": "^27.5.1", + "jest": "^29.7.0", "jest-it-up": "^2.0.2", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typescript": "~5.3.3" }, "engines": { diff --git a/packages/eth-json-rpc-provider/src/index.test.ts b/packages/eth-json-rpc-provider/src/index.test.ts index 1fa713b79bd..63ae8ab36ee 100644 --- a/packages/eth-json-rpc-provider/src/index.test.ts +++ b/packages/eth-json-rpc-provider/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('Package exports', () => { it('has expected exports', () => { expect(Object.keys(allExports).sort()).toMatchInlineSnapshot(` - Array [ + [ "InternalProvider", "SafeEventEmitterProvider", "providerFromMiddleware", diff --git a/packages/foundryup/package.json b/packages/foundryup/package.json index 8e549b05e08..e48fdd50b12 100644 --- a/packages/foundryup/package.json +++ b/packages/foundryup/package.json @@ -51,15 +51,15 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/unzipper": "^0.10.10", "@types/yargs": "^17.0.32", "@types/yargs-parser": "^21.0.3", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "yaml": "^2.3.4" diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index cdc236b8978..284a1b4e433 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -64,15 +64,14 @@ "@babel/runtime": "^7.23.9", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/jest-when": "^2.7.3", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "jest-when": "^3.4.2", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/gas-fee-controller/src/GasFeeController.test.ts b/packages/gas-fee-controller/src/GasFeeController.test.ts index 75685ef8329..a261700150a 100644 --- a/packages/gas-fee-controller/src/GasFeeController.test.ts +++ b/packages/gas-fee-controller/src/GasFeeController.test.ts @@ -18,7 +18,6 @@ import type { } from '@metamask/network-controller'; import type { Hex } from '@metamask/utils'; import nock from 'nock'; -import * as sinon from 'sinon'; import determineGasFeeCalculations from './determineGasFeeCalculations'; import { @@ -74,12 +73,10 @@ const getRootMessenger = (): RootMessenger => { const setupNetworkController = async ({ rootMessenger, state, - clock, initializeProvider = true, }: { rootMessenger: RootMessenger; state: Partial; - clock: sinon.SinonFakeTimers; initializeProvider?: boolean; }) => { const networkControllerMessenger = new Messenger< @@ -128,7 +125,7 @@ const setupNetworkController = async ({ networkController.initializeProvider(); // Ensure that the request for eth_getBlockByNumber made by the PollingBlockTracker // inside the NetworkController goes through - await clock.nextAsync(); + await jest.advanceTimersToNextTimerAsync(); } return networkController; @@ -256,7 +253,6 @@ function buildMockGasFeeStateEthGasPrice({ } describe('GasFeeController', () => { - let clock: sinon.SinonFakeTimers; let gasFeeController: GasFeeController; let networkController: NetworkController; @@ -312,7 +308,6 @@ describe('GasFeeController', () => { networkController = await setupNetworkController({ rootMessenger, state: networkControllerState, - clock, initializeProvider: initializeNetworkProvider, }); const restrictedMessenger = getGasFeeControllerMessenger(rootMessenger); @@ -332,7 +327,7 @@ describe('GasFeeController', () => { } beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); mockedDetermineGasFeeCalculations.mockResolvedValue( buildMockGasFeeStateFeeMarket(), ); @@ -344,7 +339,7 @@ describe('GasFeeController', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-floating-promises blockTracker?.destroy(); - sinon.restore(); + jest.useRealTimers(); }); describe('constructor', () => { @@ -433,8 +428,10 @@ describe('GasFeeController', () => { }); it('should continue updating the state with all estimate data (including new time estimates because of a subsequent call to determineGasFeeCalculations) on a set interval', async () => { + const pollingInterval = 10000; + await setupGasFeeController({ interval: pollingInterval }); await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.nextAsync(); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(gasFeeController.state).toMatchObject( mockDetermineGasFeeCalculationsReturnValues[1], @@ -501,7 +498,7 @@ describe('GasFeeController', () => { await gasFeeController.getGasFeeEstimatesAndStartPolling( 'some-previously-unseen-token', ); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); }); @@ -524,8 +521,8 @@ describe('GasFeeController', () => { await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(3); }); @@ -549,8 +546,8 @@ describe('GasFeeController', () => { const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); - await clock.tickAsync(pollingInterval); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(4); }); @@ -582,8 +579,8 @@ describe('GasFeeController', () => { await gasFeeController.getGasFeeEstimatesAndStartPolling( 'some-previously-unseen-token-2', ); - await clock.tickAsync(pollingInterval); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(3); }); @@ -632,12 +629,12 @@ describe('GasFeeController', () => { await setupGasFeeController({ interval: pollingInterval }); const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); gasFeeController.disconnectPoller(pollToken); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); }); @@ -646,13 +643,13 @@ describe('GasFeeController', () => { await setupGasFeeController({ interval: pollingInterval }); const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); gasFeeController.disconnectPoller(pollToken); await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(4); }); }); @@ -662,12 +659,12 @@ describe('GasFeeController', () => { const pollingInterval = 10000; await setupGasFeeController({ interval: pollingInterval }); await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); gasFeeController.disconnectPoller('some-previously-unseen-token'); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(3); }); }); @@ -681,12 +678,12 @@ describe('GasFeeController', () => { const pollToken1 = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(1); gasFeeController.disconnectPoller(pollToken1); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); }); }); @@ -707,12 +704,12 @@ describe('GasFeeController', () => { const pollingInterval = 10000; await setupGasFeeController({ interval: pollingInterval }); await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); gasFeeController.stopPolling(); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); }); @@ -721,13 +718,13 @@ describe('GasFeeController', () => { await setupGasFeeController({ interval: pollingInterval }); const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(2); gasFeeController.stopPolling(); await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(4); }); @@ -753,12 +750,12 @@ describe('GasFeeController', () => { const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(3); gasFeeController.stopPolling(); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(3); }); @@ -768,13 +765,13 @@ describe('GasFeeController', () => { const pollToken = await gasFeeController.getGasFeeEstimatesAndStartPolling(undefined); await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(3); gasFeeController.stopPolling(); await gasFeeController.getGasFeeEstimatesAndStartPolling(pollToken); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(5); }); }); @@ -1241,7 +1238,7 @@ describe('GasFeeController', () => { gasFeeController.startPolling({ networkClientId: 'linea-sepolia', }); - await clock.tickAsync(0); + await jest.advanceTimersByTimeAsync(0); expect(mockedDetermineGasFeeCalculations).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -1250,9 +1247,9 @@ describe('GasFeeController', () => { )}`, }), ); - await clock.tickAsync(pollingInterval / 2); + await jest.advanceTimersByTimeAsync(pollingInterval / 2); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledTimes(1); - await clock.tickAsync(pollingInterval / 2); + await jest.advanceTimersByTimeAsync(pollingInterval / 2); expect(mockedDetermineGasFeeCalculations).toHaveBeenNthCalledWith( 2, expect.objectContaining({ @@ -1270,7 +1267,7 @@ describe('GasFeeController', () => { gasFeeController.startPolling({ networkClientId: 'sepolia', }); - await clock.tickAsync(pollingInterval); + await jest.advanceTimersByTimeAsync(pollingInterval); expect(mockedDetermineGasFeeCalculations).toHaveBeenCalledWith( expect.objectContaining({ fetchGasEstimatesUrl: `https://some-eip-1559-endpoint/${convertHexToDecimal( @@ -1293,7 +1290,7 @@ describe('GasFeeController', () => { gasFeeController.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -1304,11 +1301,11 @@ describe('GasFeeController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "estimatedGasFeeTimeBounds": Object {}, + { + "estimatedGasFeeTimeBounds": {}, "gasEstimateType": "none", - "gasFeeEstimates": Object {}, - "gasFeeEstimatesByChainId": Object {}, + "gasFeeEstimates": {}, + "gasFeeEstimatesByChainId": {}, "nonRPCGasFeeApisDisabled": false, } `); @@ -1322,11 +1319,11 @@ describe('GasFeeController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "estimatedGasFeeTimeBounds": Object {}, + { + "estimatedGasFeeTimeBounds": {}, "gasEstimateType": "none", - "gasFeeEstimates": Object {}, - "gasFeeEstimatesByChainId": Object {}, + "gasFeeEstimates": {}, + "gasFeeEstimatesByChainId": {}, "nonRPCGasFeeApisDisabled": false, } `); @@ -1340,11 +1337,11 @@ describe('GasFeeController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "estimatedGasFeeTimeBounds": Object {}, + { + "estimatedGasFeeTimeBounds": {}, "gasEstimateType": "none", - "gasFeeEstimates": Object {}, - "gasFeeEstimatesByChainId": Object {}, + "gasFeeEstimates": {}, + "gasFeeEstimatesByChainId": {}, } `); }); diff --git a/packages/gator-permissions-controller/CHANGELOG.md b/packages/gator-permissions-controller/CHANGELOG.md index eabf6117ded..26780613b48 100644 --- a/packages/gator-permissions-controller/CHANGELOG.md +++ b/packages/gator-permissions-controller/CHANGELOG.md @@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] + ### Changed -- Bump `@metamask/transaction-controller` from `^62.11.0` to `^62.16.0` ([#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) +- **BREAKING:** Refactor `GatorPermissionsController`: simplified config, permission storage, and public API ([#7847](https://github.com/MetaMask/core/pull/7847)) + - Constructor now requires `config`, internal configuration is removed from controller state + - New `initialize()` function performs a syncronisation process if required when the controller is first initialized + - Replaces `gatorPermissionsMapSerialized` with `grantedPermissions` property in internal state, replaces related types, and utility functions + - `fetchAndUpdateGatorPermissions()` no longer accepts parameters and resolves to `void` + - `getPendingRevocations` / `pendingRevocations` getter replaced by `isPendingRevocation(permissionContext)`; list on `state.pendingRevocations` +- Bump `@metamask/transaction-controller` from `^62.11.0` to `^62.17.0` ([#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) ## [1.1.2] @@ -154,7 +162,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6033](https://github.com/MetaMask/core/pull/6033)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@1.1.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@2.0.0...HEAD +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@1.1.2...@metamask/gator-permissions-controller@2.0.0 [1.1.2]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@1.1.1...@metamask/gator-permissions-controller@1.1.2 [1.1.1]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@1.1.0...@metamask/gator-permissions-controller@1.1.1 [1.1.0]: https://github.com/MetaMask/core/compare/@metamask/gator-permissions-controller@1.0.0...@metamask/gator-permissions-controller@1.1.0 diff --git a/packages/gator-permissions-controller/README.md b/packages/gator-permissions-controller/README.md index a694e389c36..973d049fde1 100644 --- a/packages/gator-permissions-controller/README.md +++ b/packages/gator-permissions-controller/README.md @@ -17,13 +17,21 @@ or ```typescript import { GatorPermissionsController } from '@metamask/gator-permissions-controller'; -// Create the controller +// Create the controller with required config const gatorPermissionsController = new GatorPermissionsController({ messenger: yourMessenger, + config: { + supportedPermissionTypes: [ + 'native-token-stream', + 'native-token-periodic', + 'erc20-token-stream', + 'erc20-token-periodic', + 'erc20-token-revocation', + ], + // Optional: override the default gator permissions provider Snap id + // gatorPermissionsProviderSnapId: 'npm:@metamask/gator-permissions-snap', + }, }); - -// Enable the feature (requires authentication) -gatorPermissionsController.enableGatorPermissions(); ``` ### Fetch from Profile Sync @@ -33,12 +41,9 @@ gatorPermissionsController.enableGatorPermissions(); const permissions = await gatorPermissionsController.fetchAndUpdateGatorPermissions(); -// Fetch permissions with optional filter params -const filteredPermissions = - await gatorPermissionsController.fetchAndUpdateGatorPermissions({ - origin: 'https://example.com', - chainId: '0x1', - }); +// Fetch permissions and update internal state +const permissions = + await gatorPermissionsController.fetchAndUpdateGatorPermissions(); ``` ## Contributing diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index a3c4040b12e..250d7854854 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/gator-permissions-controller", - "version": "1.1.2", + "version": "2.0.0", "description": "Controller for managing gator permissions with profile sync integration", "keywords": [ "MetaMask", @@ -56,7 +56,7 @@ "@metamask/snaps-controllers": "^17.2.0", "@metamask/snaps-sdk": "^10.3.0", "@metamask/snaps-utils": "^11.7.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0" }, "devDependencies": { @@ -64,11 +64,11 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts index 6db73496832..47c1cc0c715 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.test.ts @@ -26,18 +26,14 @@ import { GatorPermissionsFetchError } from './errors'; import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController'; import GatorPermissionsController from './GatorPermissionsController'; import { - mockCustomPermissionStorageEntry, - mockErc20TokenPeriodicStorageEntry, - mockErc20TokenStreamStorageEntry, mockGatorPermissionsStorageEntriesFactory, - mockNativeTokenPeriodicStorageEntry, mockNativeTokenStreamStorageEntry, } from './test/mocks'; import type { - GatorPermissionsMap, + PermissionInfoWithMetadata, StoredGatorPermission, - PermissionTypesWithCustom, RevocationParams, + SupportedPermissionType, } from './types'; import { flushPromises } from '../../../tests/helpers'; @@ -45,139 +41,105 @@ const MOCK_CHAIN_ID_1: Hex = '0xaa36a7'; const MOCK_CHAIN_ID_2: Hex = '0x1'; const MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID = 'local:http://localhost:8082' as SnapId; -const MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES: StoredGatorPermission[] = + +const DEFAULT_TEST_CONFIG = { + supportedPermissionTypes: [ + 'native-token-stream', + 'native-token-periodic', + 'erc20-token-stream', + 'erc20-token-periodic', + 'erc20-token-revocation', + ] as SupportedPermissionType[], +}; + +const MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES: StoredGatorPermission[] = mockGatorPermissionsStorageEntriesFactory({ [MOCK_CHAIN_ID_1]: { nativeTokenStream: 5, nativeTokenPeriodic: 5, erc20TokenStream: 5, erc20TokenPeriodic: 5, - custom: { - count: 2, - data: [ - { - customData: 'customData-0', - }, - { - customData: 'customData-1', - }, - ], - }, }, [MOCK_CHAIN_ID_2]: { nativeTokenStream: 5, nativeTokenPeriodic: 5, erc20TokenStream: 5, erc20TokenPeriodic: 5, - custom: { - count: 2, - data: [ - { - customData: 'customData-0', - }, - { - customData: 'customData-1', - }, - ], - }, }, }); describe('GatorPermissionsController', () => { describe('constructor', () => { - it('creates GatorPermissionsController with default state', () => { + it('creates GatorPermissionsController with config and default state', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, }); - expect(controller.state.isGatorPermissionsEnabled).toBe(false); - expect(controller.state.gatorPermissionsMapSerialized).toStrictEqual( - JSON.stringify({ - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - }), - ); + expect(controller.state.grantedPermissions).toStrictEqual([]); expect(controller.state.isFetchingGatorPermissions).toBe(false); + expect(controller.state.lastSyncedTimestamp).toBe(-1); + expect(controller.supportedPermissionTypes).toStrictEqual( + DEFAULT_TEST_CONFIG.supportedPermissionTypes, + ); }); - it('creates GatorPermissionsController with custom state', () => { + it('creates GatorPermissionsController with config and state override', () => { const customState = { - isGatorPermissionsEnabled: true, - gatorPermissionsMapSerialized: JSON.stringify({ - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - }), - gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + grantedPermissions: [] as PermissionInfoWithMetadata[], pendingRevocations: [], + lastSyncedTimestamp: -1, }; const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, state: customState, }); - expect(controller.state.gatorPermissionsProviderSnapId).toBe( + expect(controller.gatorPermissionsProviderSnapId).toBe( MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, ); - expect(controller.state.isGatorPermissionsEnabled).toBe(true); - expect(controller.state.gatorPermissionsMapSerialized).toBe( - customState.gatorPermissionsMapSerialized, - ); + expect(controller.state.grantedPermissions).toStrictEqual([]); }); - it('creates GatorPermissionsController with default config', () => { + it('creates GatorPermissionsController with specified gatorPermissionsProviderSnapId', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: { + ...DEFAULT_TEST_CONFIG, + gatorPermissionsProviderSnapId: + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, }); - expect(controller.permissionsProviderSnapId).toBe( - 'npm:@metamask/gator-permissions-snap' as SnapId, + expect(controller.gatorPermissionsProviderSnapId).toBe( + MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, ); - expect(controller.state.isGatorPermissionsEnabled).toBe(false); expect(controller.state.isFetchingGatorPermissions).toBe(false); }); it('isFetchingGatorPermissions is false on initialization', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), - state: { - isFetchingGatorPermissions: true, - }, + config: DEFAULT_TEST_CONFIG, }); expect(controller.state.isFetchingGatorPermissions).toBe(false); }); - }); - describe('disableGatorPermissions', () => { - it('disables gator permissions successfully', async () => { + it('isFetchingGatorPermissions is always false when the controller is created', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, + state: { isFetchingGatorPermissions: true }, }); - await controller.enableGatorPermissions(); - expect(controller.state.isGatorPermissionsEnabled).toBe(true); - - await controller.disableGatorPermissions(); - - expect(controller.state.isGatorPermissionsEnabled).toBe(false); - expect(controller.state.gatorPermissionsMapSerialized).toBe( - JSON.stringify({ - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - }), - ); + expect(controller.state.isFetchingGatorPermissions).toBe(false); }); }); @@ -215,58 +177,35 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.enableGatorPermissions(); - - const result = await controller.fetchAndUpdateGatorPermissions(); - - expect(result).toStrictEqual({ - 'erc20-token-revocation': expect.any(Object), - 'native-token-stream': expect.any(Object), - 'native-token-periodic': expect.any(Object), - 'erc20-token-stream': expect.any(Object), - 'erc20-token-periodic': expect.any(Object), - other: expect.any(Object), - }); + await controller.fetchAndUpdateGatorPermissions(); - // Check that each permission type has the expected chainId - expect( - result['native-token-stream'][MOCK_CHAIN_ID_1].length, - ).toBeGreaterThanOrEqual(5); - expect(result['native-token-periodic'][MOCK_CHAIN_ID_1]).toHaveLength(5); - expect(result['erc20-token-stream'][MOCK_CHAIN_ID_1]).toHaveLength(5); - expect(result['native-token-stream'][MOCK_CHAIN_ID_2]).toHaveLength(5); - expect(result['native-token-periodic'][MOCK_CHAIN_ID_2]).toHaveLength(5); - expect(result['erc20-token-stream'][MOCK_CHAIN_ID_2]).toHaveLength(5); - expect(result.other[MOCK_CHAIN_ID_1]).toHaveLength(2); - expect(result.other[MOCK_CHAIN_ID_2]).toHaveLength(2); + const { grantedPermissions } = controller.state; + expect(Array.isArray(grantedPermissions)).toBe(true); + expect(grantedPermissions).toHaveLength( + mockStorageEntriesWithRules.length, + ); expect(controller.state.isFetchingGatorPermissions).toBe(false); + expect(controller.state.lastSyncedTimestamp).not.toBe(-1); - // check that the gator permissions map is sanitized - const sanitizedCheck = ( - permissionType: keyof GatorPermissionsMap, - ): void => { - const flattenedStoredGatorPermissions = Object.values( - result[permissionType], - ).flat(); - flattenedStoredGatorPermissions.forEach((permission) => { - expect(permission.permissionResponse.to).toBeUndefined(); - expect(permission.permissionResponse.dependencies).toBeUndefined(); - }); - }; - - sanitizedCheck('native-token-stream'); - sanitizedCheck('native-token-periodic'); - sanitizedCheck('erc20-token-stream'); - sanitizedCheck('erc20-token-periodic'); - sanitizedCheck('erc20-token-revocation'); - sanitizedCheck('other'); + grantedPermissions.forEach((entry) => { + expect(entry.permissionResponse).toBeDefined(); + expect(entry.siteOrigin).toBeDefined(); + // Sanitized response omits internal fields (to, dependencies) + expect( + (entry.permissionResponse as Record).to, + ).toBeUndefined(); + expect( + (entry.permissionResponse as Record).dependencies, + ).toBeUndefined(); + }); // Specifically verify that the entry with rules has rules preserved - const entryWithRules = result['native-token-stream'][ - MOCK_CHAIN_ID_1 - ].find((entry) => entry.permissionResponse.rules !== undefined); + const entryWithRules = grantedPermissions.find( + (entry) => entry.permissionResponse.rules !== undefined, + ); expect(entryWithRules).toBeDefined(); expect(entryWithRules?.permissionResponse.rules).toBeDefined(); expect(entryWithRules?.permissionResponse.rules).toStrictEqual([ @@ -309,74 +248,99 @@ describe('GatorPermissionsController', () => { }); const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.enableGatorPermissions(); - const result = await controller.fetchAndUpdateGatorPermissions(); + await controller.fetchAndUpdateGatorPermissions(); - expect(result['erc20-token-revocation']).toBeDefined(); - expect(result['erc20-token-revocation'][chainId]).toHaveLength(1); + const { grantedPermissions } = controller.state; + expect(grantedPermissions).toHaveLength(1); + expect(grantedPermissions[0].permissionResponse.permission.type).toBe( + 'erc20-token-revocation', + ); + expect(grantedPermissions[0].permissionResponse.chainId).toBe(chainId); }); - it('throws error when gator permissions are not enabled', async () => { + it('handles null permissions data', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => null, + }); + const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(), + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.disableGatorPermissions(); + await controller.fetchAndUpdateGatorPermissions(); - await expect(controller.fetchAndUpdateGatorPermissions()).rejects.toThrow( - 'Failed to fetch gator permissions', - ); + expect(controller.state.grantedPermissions).toStrictEqual([]); }); - it('handles null permissions data', async () => { + it('handles empty permissions data', async () => { const rootMessenger = getRootMessenger({ - snapControllerHandleRequestActionHandler: async () => null, + snapControllerHandleRequestActionHandler: async () => [], }); const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.enableGatorPermissions(); + await controller.fetchAndUpdateGatorPermissions(); - const result = await controller.fetchAndUpdateGatorPermissions(); + expect(controller.state.grantedPermissions).toStrictEqual([]); + }); - expect(result).toStrictEqual({ - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, + it('handles error during fetch and update', async () => { + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: async () => { + throw new Error('Storage error'); + }, }); + + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + }); + + await expect(controller.fetchAndUpdateGatorPermissions()).rejects.toThrow( + 'Failed to fetch gator permissions', + ); + + expect(controller.state.isFetchingGatorPermissions).toBe(false); + expect(controller.state.lastSyncedTimestamp).toBe(-1); }); - it('handles empty permissions data', async () => { + it('returns the same promise when called concurrently', async () => { + let resolveRequest: + | ((value: StoredGatorPermission[]) => void) + | undefined; + + const requestPromise = new Promise((resolve) => { + resolveRequest = resolve; + }); + const mockHandleRequestHandler = jest + .fn() + .mockReturnValue(requestPromise); const rootMessenger = getRootMessenger({ - snapControllerHandleRequestActionHandler: async () => [], + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, }); const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.enableGatorPermissions(); + const promise1 = controller.fetchAndUpdateGatorPermissions(); + const promise2 = controller.fetchAndUpdateGatorPermissions(); - const result = await controller.fetchAndUpdateGatorPermissions(); + expect(promise1).toBe(promise2); - expect(result).toStrictEqual({ - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - }); + resolveRequest?.(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + await promise1; }); - it('fetches gator permissions with optional params', async () => { + it('performs a new sync when called after previous sync completes', async () => { const mockHandleRequestHandler = jest .fn() .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); @@ -385,95 +349,109 @@ describe('GatorPermissionsController', () => { }); const controller = new GatorPermissionsController({ - messenger: getMessenger(rootMessenger), + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.enableGatorPermissions(); - - const params = { origin: 'https://example.com', chainId: '0x1' }; - await controller.fetchAndUpdateGatorPermissions(params); + await controller.fetchAndUpdateGatorPermissions(); + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); - expect(mockHandleRequestHandler).toHaveBeenCalledWith( - expect.objectContaining({ - request: expect.objectContaining({ - params, - }), - }), - ); + await controller.fetchAndUpdateGatorPermissions(); + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(2); }); + }); - it('handles error during fetch and update', async () => { + describe('initialize', () => { + it('calls fetchAndUpdateGatorPermissions when lastSyncedTimestamp is -1', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); const rootMessenger = getRootMessenger({ - snapControllerHandleRequestActionHandler: async () => { - throw new Error('Storage error'); - }, + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, }); const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, }); - await controller.enableGatorPermissions(); + expect(controller.state.lastSyncedTimestamp).toBe(-1); - await expect(controller.fetchAndUpdateGatorPermissions()).rejects.toThrow( - 'Failed to fetch gator permissions', - ); + await controller.initialize(); - expect(controller.state.isFetchingGatorPermissions).toBe(false); + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + expect(controller.state.lastSyncedTimestamp).not.toBe(-1); + expect(controller.state.grantedPermissions.length).toBeGreaterThan(0); }); - }); - describe('gatorPermissionsMap getter tests', () => { - it('returns parsed gator permissions map', () => { + it('does not call fetchAndUpdateGatorPermissions when lastSyncedTimestamp is recent', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + + const recentTimestamp = Date.now() - 1000; // 1 second ago const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(), + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + state: { lastSyncedTimestamp: recentTimestamp }, }); - const { gatorPermissionsMap } = controller; + await controller.initialize(); + + expect(mockHandleRequestHandler).not.toHaveBeenCalled(); + expect(controller.state.lastSyncedTimestamp).toBe(recentTimestamp); + }); - expect(gatorPermissionsMap).toStrictEqual({ - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, + it('calls fetchAndUpdateGatorPermissions when lastSyncedTimestamp is older than sync interval', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + + const thirtyOneDaysMs = 31 * 24 * 60 * 60 * 1000; + const staleTimestamp = Date.now() - thirtyOneDaysMs; + const controller = new GatorPermissionsController({ + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: DEFAULT_TEST_CONFIG, + state: { lastSyncedTimestamp: staleTimestamp }, }); + + await controller.initialize(); + + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + expect(controller.state.lastSyncedTimestamp).not.toBe(staleTimestamp); }); - it('returns parsed gator permissions map with data when state is provided', () => { - const mockState = { - 'native-token-stream': { - '0x1': [mockNativeTokenStreamStorageEntry('0x1')], - }, - 'native-token-periodic': { - '0x2': [mockNativeTokenPeriodicStorageEntry('0x2')], - }, - 'erc20-token-stream': { - '0x3': [mockErc20TokenStreamStorageEntry('0x3')], - }, - 'erc20-token-periodic': { - '0x4': [mockErc20TokenPeriodicStorageEntry('0x4')], - }, - other: { - '0x5': [ - mockCustomPermissionStorageEntry('0x5', { - customData: 'customData-0', - }), - ], - }, - }; + it('respects custom maxSyncIntervalMs from config', async () => { + const mockHandleRequestHandler = jest + .fn() + .mockResolvedValue(MOCK_GATOR_PERMISSIONS_STORAGE_ENTRIES); + const rootMessenger = getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }); + const maxSyncIntervalMs = 500; + const lastSyncedTwoSecondsAgo = Date.now() - 2000; const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(), - state: { - gatorPermissionsMapSerialized: JSON.stringify(mockState), + messenger: getGatorPermissionsControllerMessenger(rootMessenger), + config: { + ...DEFAULT_TEST_CONFIG, + maxSyncIntervalMs, }, + state: { lastSyncedTimestamp: lastSyncedTwoSecondsAgo }, }); - const { gatorPermissionsMap } = controller; + await controller.initialize(); - expect(gatorPermissionsMap).toStrictEqual(mockState); + expect(mockHandleRequestHandler).toHaveBeenCalledTimes(1); + expect(controller.state.lastSyncedTimestamp).not.toBe( + lastSyncedTwoSecondsAgo, + ); }); }); @@ -487,33 +465,15 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, + config: DEFAULT_TEST_CONFIG, }); - expect(controller.state.isGatorPermissionsEnabled).toBe(false); + expect(controller).toBeDefined(); + expect(mockRegisterActionHandler).toHaveBeenCalledWith( 'GatorPermissionsController:fetchAndUpdateGatorPermissions', expect.any(Function), ); - expect(mockRegisterActionHandler).toHaveBeenCalledWith( - 'GatorPermissionsController:enableGatorPermissions', - expect.any(Function), - ); - expect(mockRegisterActionHandler).toHaveBeenCalledWith( - 'GatorPermissionsController:disableGatorPermissions', - expect.any(Function), - ); - }); - }); - - describe('enableGatorPermissions', () => { - it('enables gator permissions successfully', async () => { - const controller = new GatorPermissionsController({ - messenger: getGatorPermissionsControllerMessenger(), - }); - - await controller.enableGatorPermissions(); - - expect(controller.state.isGatorPermissionsEnabled).toBe(true); }); }); @@ -521,6 +481,7 @@ describe('GatorPermissionsController', () => { it('includes expected state in debug snapshots', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, }); expect( @@ -529,12 +490,13 @@ describe('GatorPermissionsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, }); expect( @@ -544,12 +506,11 @@ describe('GatorPermissionsController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "gatorPermissionsMapSerialized": "{\\"erc20-token-revocation\\":{},\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", - "gatorPermissionsProviderSnapId": "npm:@metamask/gator-permissions-snap", + { + "grantedPermissions": [], "isFetchingGatorPermissions": false, - "isGatorPermissionsEnabled": false, - "pendingRevocations": Array [], + "lastSyncedTimestamp": -1, + "pendingRevocations": [], } `); }); @@ -557,6 +518,7 @@ describe('GatorPermissionsController', () => { it('persists expected state', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, }); expect( @@ -566,9 +528,9 @@ describe('GatorPermissionsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "gatorPermissionsMapSerialized": "{\\"erc20-token-revocation\\":{},\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", - "isGatorPermissionsEnabled": false, + { + "grantedPermissions": [], + "lastSyncedTimestamp": -1, } `); }); @@ -576,6 +538,7 @@ describe('GatorPermissionsController', () => { it('exposes expected state to UI', () => { const controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, }); expect( @@ -585,9 +548,10 @@ describe('GatorPermissionsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "gatorPermissionsMapSerialized": "{\\"erc20-token-revocation\\":{},\\"native-token-stream\\":{},\\"native-token-periodic\\":{},\\"erc20-token-stream\\":{},\\"erc20-token-periodic\\":{},\\"other\\":{}}", - "pendingRevocations": Array [], + { + "grantedPermissions": [], + "isFetchingGatorPermissions": false, + "pendingRevocations": [], } `); }); @@ -615,13 +579,14 @@ describe('GatorPermissionsController', () => { beforeEach(() => { controller = new GatorPermissionsController({ messenger: getGatorPermissionsControllerMessenger(), + config: DEFAULT_TEST_CONFIG, }); }); it('throws if contracts are not found', () => { expect(() => controller.decodePermissionFromPermissionContextForOrigin({ - origin: controller.permissionsProviderSnapId, + origin: controller.gatorPermissionsProviderSnapId, chainId: 999999, delegation: { caveats: [], @@ -683,7 +648,7 @@ describe('GatorPermissionsController', () => { }; const result = controller.decodePermissionFromPermissionContextForOrigin({ - origin: controller.permissionsProviderSnapId, + origin: controller.gatorPermissionsProviderSnapId, chainId, delegation, metadata: buildMetadata('Test justification'), @@ -743,7 +708,7 @@ describe('GatorPermissionsController', () => { expect(() => controller.decodePermissionFromPermissionContextForOrigin({ - origin: controller.permissionsProviderSnapId, + origin: controller.gatorPermissionsProviderSnapId, chainId, delegation: { delegate: delegatorAddressA, @@ -802,7 +767,7 @@ describe('GatorPermissionsController', () => { expect(() => controller.decodePermissionFromPermissionContextForOrigin({ - origin: controller.permissionsProviderSnapId, + origin: controller.gatorPermissionsProviderSnapId, chainId, delegation: { delegate, @@ -827,10 +792,12 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { pendingRevocations: [ { txId: 'test-tx-id', @@ -857,16 +824,19 @@ describe('GatorPermissionsController', () => { params: revocationParams, }, }); - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); - it('should throw GatorPermissionsNotEnabledError when gator permissions are disabled', async () => { - const messenger = getMessenger(); + it('should submit revocation when controller is configured', async () => { + const mockHandleRequestHandler = jest.fn().mockResolvedValue(undefined); + const messenger = getMessenger( + getRootMessenger({ + snapControllerHandleRequestActionHandler: mockHandleRequestHandler, + }), + ); const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: false, - }, + config: DEFAULT_TEST_CONFIG, }); const revocationParams: RevocationParams = { @@ -874,9 +844,9 @@ describe('GatorPermissionsController', () => { txHash: undefined, }; - await expect( - controller.submitRevocation(revocationParams), - ).rejects.toThrow('Gator permissions are not enabled'); + expect( + await controller.submitRevocation(revocationParams), + ).toBeUndefined(); }); it('should throw GatorPermissionsProviderError when snap request fails', async () => { @@ -891,8 +861,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -920,10 +890,12 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { pendingRevocations: [ { txId: 'test-tx-id', @@ -962,7 +934,7 @@ describe('GatorPermissionsController', () => { ); // Pending revocation should still be cleared despite refresh failure - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); }); @@ -977,8 +949,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1004,7 +976,7 @@ describe('GatorPermissionsController', () => { }); // Pending revocation should be cleared after successful submission - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should add pending revocation with placeholder txId', async () => { @@ -1017,8 +989,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1039,26 +1011,7 @@ describe('GatorPermissionsController', () => { // Verify that pending revocation was added (before submitRevocation clears it) // We check by verifying submitRevocation was called, which clears pending expect(submitRevocationSpy).toHaveBeenCalledWith(revocationParams); - expect(controller.pendingRevocations).toStrictEqual([]); - }); - - it('should throw GatorPermissionsNotEnabledError when gator permissions are disabled', async () => { - const messenger = getMessenger(); - const controller = new GatorPermissionsController({ - messenger, - state: { - isGatorPermissionsEnabled: false, - }, - }); - - const revocationParams: RevocationParams = { - permissionContext: '0x1234567890abcdef1234567890abcdef12345678', - txHash: undefined, - }; - - await expect( - controller.submitDirectRevocation(revocationParams), - ).rejects.toThrow('Gator permissions are not enabled'); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should clear pending revocation even if submitRevocation fails (finally block)', async () => { @@ -1073,8 +1026,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1095,7 +1048,7 @@ describe('GatorPermissionsController', () => { // Pending revocation is cleared in finally block even if submission failed // This prevents stuck state, though the error is still thrown for caller handling - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); }); @@ -1106,10 +1059,12 @@ describe('GatorPermissionsController', () => { '0x1234567890abcdef1234567890abcdef12345678' as Hex; const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { pendingRevocations: [ { txId: 'test-tx-id', @@ -1126,10 +1081,12 @@ describe('GatorPermissionsController', () => { const messenger = getMessenger(); const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { pendingRevocations: [ { txId: 'test-tx-id', @@ -1152,10 +1109,12 @@ describe('GatorPermissionsController', () => { '0x1234567890abcdef1234567890abcdef12345678' as Hex; const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, + }, + state: { pendingRevocations: [ { txId: 'test-tx-id', @@ -1189,8 +1148,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1248,8 +1207,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1289,7 +1248,7 @@ describe('GatorPermissionsController', () => { }); // Should not be in pending revocations - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should submit revocation metadata when transaction is confirmed', async () => { @@ -1301,8 +1260,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1365,8 +1324,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1378,7 +1337,7 @@ describe('GatorPermissionsController', () => { await controller.addPendingRevocation({ txId, permissionContext }); // Verify pending revocation is not in state yet - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); // Emit transaction rejected event (user cancels) rootMessenger.publish('TransactionController:transactionRejected', { @@ -1391,7 +1350,7 @@ describe('GatorPermissionsController', () => { // Should not call submitRevocation expect(mockHandleRequestHandler).not.toHaveBeenCalled(); // Should not be in pending revocations - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should cleanup and refresh permissions without submitting revocation when transaction fails', async () => { @@ -1403,8 +1362,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1437,7 +1396,7 @@ describe('GatorPermissionsController', () => { }); // Should not be in pending revocations - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should cleanup and refresh permissions without submitting revocation when transaction is dropped', async () => { @@ -1449,8 +1408,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1482,7 +1441,7 @@ describe('GatorPermissionsController', () => { }); // Should not be in pending revocations - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should handle error when refreshing permissions after transaction fails', async () => { @@ -1495,8 +1454,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1521,7 +1480,7 @@ describe('GatorPermissionsController', () => { expect(mockHandleRequestHandler).toHaveBeenCalled(); // Should not be in pending revocations - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should handle error when refreshing permissions after transaction is dropped', async () => { @@ -1534,8 +1493,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1559,7 +1518,7 @@ describe('GatorPermissionsController', () => { expect(mockHandleRequestHandler).toHaveBeenCalled(); // Should not be in pending revocations - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); }); it('should cleanup without submitting revocation when timeout is reached', async () => { @@ -1571,8 +1530,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1602,8 +1561,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1615,7 +1574,7 @@ describe('GatorPermissionsController', () => { await controller.addPendingRevocation({ txId, permissionContext }); // Before approval, pending revocation should not be in state - expect(controller.pendingRevocations).toStrictEqual([]); + expect(controller.state.pendingRevocations).toStrictEqual([]); // Emit transaction approved event (user confirms) rootMessenger.publish('TransactionController:transactionApproved', { @@ -1623,7 +1582,7 @@ describe('GatorPermissionsController', () => { }); // After approval, pending revocation should be in state - expect(controller.pendingRevocations).toStrictEqual([ + expect(controller.state.pendingRevocations).toStrictEqual([ { txId, permissionContext }, ]); }); @@ -1637,8 +1596,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1678,8 +1637,8 @@ describe('GatorPermissionsController', () => { const controller = new GatorPermissionsController({ messenger, - state: { - isGatorPermissionsEnabled: true, + config: { + ...DEFAULT_TEST_CONFIG, gatorPermissionsProviderSnapId: MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, }, @@ -1707,50 +1666,6 @@ describe('GatorPermissionsController', () => { // Should have attempted to call submitRevocation even though it failed expect(mockHandleRequestHandler).toHaveBeenCalled(); }); - - it('should throw GatorPermissionsNotEnabledError when gator permissions are disabled', async () => { - const messenger = getMessenger(); - const controller = new GatorPermissionsController({ - messenger, - state: { - isGatorPermissionsEnabled: false, - }, - }); - - const txId = 'test-tx-id'; - const permissionContext = '0x1234567890abcdef1234567890abcdef12345678'; - - await expect( - controller.addPendingRevocation({ txId, permissionContext }), - ).rejects.toThrow('Gator permissions are not enabled'); - }); - }); - - describe('get pendingRevocations', () => { - it('should return the pending revocations list', () => { - const messenger = getMessenger(); - const controller = new GatorPermissionsController({ - messenger, - state: { - isGatorPermissionsEnabled: true, - gatorPermissionsProviderSnapId: - MOCK_GATOR_PERMISSIONS_PROVIDER_SNAP_ID, - pendingRevocations: [ - { - txId: 'test-tx-id', - permissionContext: '0x1234567890abcdef1234567890abcdef12345678', - }, - ], - }, - }); - - expect(controller.pendingRevocations).toStrictEqual([ - { - txId: 'test-tx-id', - permissionContext: '0x1234567890abcdef1234567890abcdef12345678', - }, - ]); - }); }); }); diff --git a/packages/gator-permissions-controller/src/GatorPermissionsController.ts b/packages/gator-permissions-controller/src/GatorPermissionsController.ts index 2937a8799c2..96881af131f 100644 --- a/packages/gator-permissions-controller/src/GatorPermissionsController.ts +++ b/packages/gator-permissions-controller/src/GatorPermissionsController.ts @@ -17,7 +17,7 @@ import type { TransactionControllerTransactionFailedEvent, TransactionControllerTransactionRejectedEvent, } from '@metamask/transaction-controller'; -import type { Hex, Json } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { DELEGATION_FRAMEWORK_VERSION } from './constants'; import type { DecodedPermission } from './decodePermission'; @@ -28,26 +28,21 @@ import { } from './decodePermission'; import { GatorPermissionsFetchError, - GatorPermissionsNotEnabledError, GatorPermissionsProviderError, OriginNotAllowedError, PermissionDecodingError, } from './errors'; import { controllerLog } from './logger'; import { GatorPermissionsSnapRpcMethod } from './types'; -import type { StoredGatorPermissionSanitized } from './types'; import type { - GatorPermissionsMap, - PermissionTypesWithCustom, StoredGatorPermission, + PermissionInfoWithMetadata, + SupportedPermissionType, DelegationDetails, RevocationParams, PendingRevocationParams, } from './types'; -import { - deserializeGatorPermissionsMap, - serializeGatorPermissionsMap, -} from './utils'; +import { executeSnapRpc } from './utils'; // === GENERAL === @@ -58,16 +53,7 @@ const controllerName = 'GatorPermissionsController'; const defaultGatorPermissionsProviderSnapId = 'npm:@metamask/gator-permissions-snap' as SnapId; -const createEmptyGatorPermissionsMap: () => GatorPermissionsMap = () => { - return { - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - }; -}; +const DEFAULT_MAX_SYNC_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds /** * Timeout duration for pending revocations (2 hours in milliseconds). @@ -77,52 +63,62 @@ const PENDING_REVOCATION_TIMEOUT = 2 * 60 * 60 * 1000; const contractsByChainId = DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION]; -// === STATE === +// === CONFIG === /** - * State shape for GatorPermissionsController + * Configuration for {@link GatorPermissionsController}. */ -export type GatorPermissionsControllerState = { +export type GatorPermissionsControllerConfig = { /** - * Flag that indicates if the gator permissions feature is enabled + * Permission types the controller supports (e.g. 'native-token-stream', 'erc20-token-periodic'). */ - isGatorPermissionsEnabled: boolean; - + supportedPermissionTypes: SupportedPermissionType[]; + /** + * Optional ID of the gator permissions provider Snap. Defaults to npm:@metamask/gator-permissions-snap. + */ + gatorPermissionsProviderSnapId?: SnapId; /** - * JSON serialized object containing gator permissions fetched from profile sync + * Optional maximum age of cached permissions (ms) before {@link GatorPermissionsController.initialize} + * triggers a sync. Defaults to 30 days. */ - gatorPermissionsMapSerialized: string; + maxSyncIntervalMs?: number; +}; +// === STATE === + +/** + * State shape for {@link GatorPermissionsController}. + */ +export type GatorPermissionsControllerState = { /** - * Flag that indicates that fetching permissions is in progress - * This is used to show a loading spinner in the UI + * List of granted permissions with metadata (siteOrigin, revocationMetadata). */ - isFetchingGatorPermissions: boolean; + grantedPermissions: PermissionInfoWithMetadata[]; /** - * The ID of the Snap of the gator permissions provider snap - * Default value is `@metamask/gator-permissions-snap` + * Flag that indicates that fetching permissions is in progress + * This can be used to show a loading spinner in the UI */ - gatorPermissionsProviderSnapId: SnapId; + isFetchingGatorPermissions: boolean; /** - * List of gator permission pending a revocation transaction + * List of gator permissions pending a revocation transaction */ pendingRevocations: { txId: string; permissionContext: Hex; }[]; + + /** + * Timestamp (ms) of the last successful sync of gator permissions from profile sync. + * -1 indicates that a sync has never completed successfully. + */ + lastSyncedTimestamp: number; }; const gatorPermissionsControllerMetadata: StateMetadata = { - isGatorPermissionsEnabled: { - includeInStateLogs: true, - persist: true, - includeInDebugSnapshot: false, - usedInUi: false, - }, - gatorPermissionsMapSerialized: { + grantedPermissions: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: false, @@ -132,39 +128,39 @@ const gatorPermissionsControllerMetadata: StateMetadata; /** - * Constructs the default {@link GatorPermissionsController} 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. + * Creates initial controller state, merging defaults with optional partial state. + * Internal use only (e.g. constructor, tests). * - * @returns The default {@link GatorPermissionsController} state. + * @param state - Optional partial state to merge with defaults. + * @returns Complete {@link GatorPermissionsController} state. */ -export function getDefaultGatorPermissionsControllerState(): GatorPermissionsControllerState { +function createGatorPermissionsControllerState( + state?: Partial, +): GatorPermissionsControllerState { return { - isGatorPermissionsEnabled: false, - gatorPermissionsMapSerialized: serializeGatorPermissionsMap( - createEmptyGatorPermissionsMap(), - ), - isFetchingGatorPermissions: false, - gatorPermissionsProviderSnapId: defaultGatorPermissionsProviderSnapId, + grantedPermissions: [], pendingRevocations: [], + lastSyncedTimestamp: -1, + ...state, + // isFetchingGatorPermissions is _always_ false when the controller is created + isFetchingGatorPermissions: false, }; } @@ -187,22 +183,6 @@ export type GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction = { handler: GatorPermissionsController['fetchAndUpdateGatorPermissions']; }; -/** - * The action which can be used to enable gator permissions. - */ -export type GatorPermissionsControllerEnableGatorPermissionsAction = { - type: `${typeof controllerName}:enableGatorPermissions`; - handler: GatorPermissionsController['enableGatorPermissions']; -}; - -/** - * The action which can be used to disable gator permissions. - */ -export type GatorPermissionsControllerDisableGatorPermissionsAction = { - type: `${typeof controllerName}:disableGatorPermissions`; - handler: GatorPermissionsController['disableGatorPermissions']; -}; - export type GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction = { type: `${typeof controllerName}:decodePermissionFromPermissionContextForOrigin`; @@ -249,8 +229,6 @@ export type GatorPermissionsControllerIsPendingRevocationAction = { export type GatorPermissionsControllerActions = | GatorPermissionsControllerGetStateAction | GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction - | GatorPermissionsControllerEnableGatorPermissionsAction - | GatorPermissionsControllerDisableGatorPermissionsAction | GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction | GatorPermissionsControllerSubmitRevocationAction | GatorPermissionsControllerAddPendingRevocationAction @@ -260,7 +238,7 @@ export type GatorPermissionsControllerActions = /** * All actions that {@link GatorPermissionsController} calls internally. * - * SnapsController:handleRequest and SnapsController:has are allowed to be called + * SnapController:handleRequest and SnapController:has are allowed to be called * internally because they are used to fetch gator permissions from the Snap. */ type AllowedActions = HandleSnapRequest | HasSnap; @@ -302,50 +280,81 @@ export type GatorPermissionsControllerMessenger = Messenger< >; /** - * Controller that manages gator permissions by reading from profile sync + * Controller that manages gator permissions by reading from the gator permissions provider Snap. */ export default class GatorPermissionsController extends BaseController< typeof controllerName, GatorPermissionsControllerState, GatorPermissionsControllerMessenger > { + readonly #supportedPermissionTypes: readonly SupportedPermissionType[]; + + /** + * The Snap ID of the gator permissions provider. + * + * @returns The Snap ID of the gator permissions provider. + */ + get gatorPermissionsProviderSnapId(): SnapId { + return this.#gatorPermissionsProviderSnapId; + } + + readonly #gatorPermissionsProviderSnapId: SnapId; + + readonly #maxSyncIntervalMs: number; + + /** + * When a sync is in progress, holds the promise for that sync so concurrent + * callers receive the same promise. Cleared when the sync completes. + */ + #fetchAndUpdateGatorPermissionsPromise: Promise | null = null; + /** * Creates a GatorPermissionsController instance. * * @param args - The arguments to this function. - * @param args.messenger - Messenger used to communicate with BaseV2 controller. - * @param args.state - Initial state to set on this controller. + * @param args.messenger - Messenger used to communicate with other controllers. + * @param args.config - Configuration (supported permission types and optional Snap id). + * @param args.state - Optional partial state to merge with defaults. */ constructor({ messenger, + config, state, }: { messenger: GatorPermissionsControllerMessenger; + config: GatorPermissionsControllerConfig; state?: Partial; }) { + const initialState = createGatorPermissionsControllerState(state); + super({ name: controllerName, metadata: gatorPermissionsControllerMetadata, messenger, - state: { - ...getDefaultGatorPermissionsControllerState(), - ...state, - isFetchingGatorPermissions: false, - }, + state: initialState, }); + this.#supportedPermissionTypes = config.supportedPermissionTypes; + this.#gatorPermissionsProviderSnapId = + config.gatorPermissionsProviderSnapId ?? + defaultGatorPermissionsProviderSnapId; + this.#maxSyncIntervalMs = + config.maxSyncIntervalMs ?? DEFAULT_MAX_SYNC_INTERVAL_MS; this.#registerMessageHandlers(); } - #setIsFetchingGatorPermissions(isFetchingGatorPermissions: boolean): void { - this.update((state) => { - state.isFetchingGatorPermissions = isFetchingGatorPermissions; - }); + /** + * Supported permission types this controller was configured with. + * + * @returns The supported permission types. + */ + get supportedPermissionTypes(): readonly SupportedPermissionType[] { + return this.#supportedPermissionTypes; } - #setIsGatorPermissionsEnabled(isGatorPermissionsEnabled: boolean): void { + #setIsFetchingGatorPermissions(isFetchingGatorPermissions: boolean): void { this.update((state) => { - state.isGatorPermissionsEnabled = isGatorPermissionsEnabled; + state.isFetchingGatorPermissions = isFetchingGatorPermissions; }); } @@ -384,16 +393,6 @@ export default class GatorPermissionsController extends BaseController< this.fetchAndUpdateGatorPermissions.bind(this), ); - this.messenger.registerActionHandler( - `${controllerName}:enableGatorPermissions`, - this.enableGatorPermissions.bind(this), - ); - - this.messenger.registerActionHandler( - `${controllerName}:disableGatorPermissions`, - this.disableGatorPermissions.bind(this), - ); - this.messenger.registerActionHandler( `${controllerName}:decodePermissionFromPermissionContextForOrigin`, this.decodePermissionFromPermissionContextForOrigin.bind(this), @@ -423,212 +422,121 @@ export default class GatorPermissionsController extends BaseController< } /** - * Asserts that the gator permissions are enabled. - * - * @throws {GatorPermissionsNotEnabledError} If the gator permissions are not enabled. - */ - #assertGatorPermissionsEnabled(): void { - if (!this.state.isGatorPermissionsEnabled) { - throw new GatorPermissionsNotEnabledError(); - } - } - - /** - * Forwards a Snap request to the SnapController. + * Converts a stored gator permission to permission info with metadata. + * Strips internal fields (dependencies, to) from the permission response. * - * @param args - The request parameters. - * @param args.snapId - The ID of the Snap of the gator permissions provider snap. - * @param args.params - Optional parameters to pass to the snap method. - * @returns A promise that resolves with the gator permissions. + * @param storedGatorPermission - The stored gator permission from the Snap. + * @returns Permission info with metadata for state/UI. */ - async #handleSnapRequestToGatorPermissionsProvider({ - snapId, - params, - }: { - snapId: SnapId; - params?: Json; - }): Promise[] | null> { - try { - const response = (await this.messenger.call( - 'SnapController:handleRequest', - { - snapId, - origin: 'metamask', - handler: HandlerType.OnRpcRequest, - request: { - jsonrpc: '2.0', - method: - GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, - ...(params !== undefined && { params }), - }, - }, - )) as StoredGatorPermission[] | null; + #storedPermissionToPermissionInfo( + storedGatorPermission: StoredGatorPermission, + ): PermissionInfoWithMetadata { + const { permissionResponse: fullPermissionResponse } = + storedGatorPermission; + const { + dependencies: _dependencies, + to: _to, + ...permissionResponse + } = fullPermissionResponse; - return response; - } catch (error) { - controllerLog( - 'Failed to handle snap request to gator permissions provider', - error, - ); - throw new GatorPermissionsProviderError({ - method: - GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, - cause: error as Error, - }); - } - } - - /** - * Sanitizes a stored gator permission for client exposure. - * Removes internal fields (dependencies, to) - * - * @param storedGatorPermission - The stored gator permission to sanitize. - * @returns The sanitized stored gator permission. - */ - #sanitizeStoredGatorPermission( - storedGatorPermission: StoredGatorPermission, - ): StoredGatorPermissionSanitized { - const { permissionResponse } = storedGatorPermission; - const { dependencies, to, ...rest } = permissionResponse; return { ...storedGatorPermission, - permissionResponse: { - ...rest, - }, + permissionResponse, }; } /** - * Categorizes stored gator permissions by type and chainId. + * Converts stored gator permissions from the Snap into permission info with metadata. * - * @param storedGatorPermissions - An array of stored gator permissions. - * @returns The gator permissions map. + * @param storedGatorPermissions - Stored gator permissions returned by the Snap, or null. + * @returns Array of permission info with metadata for state. */ - #categorizePermissionsDataByTypeAndChainId( - storedGatorPermissions: - | StoredGatorPermission[] - | null, - ): GatorPermissionsMap { - const gatorPermissionsMap = createEmptyGatorPermissionsMap(); - + #storedPermissionsToPermissionInfoWithMetadata( + storedGatorPermissions: StoredGatorPermission[] | null, + ): PermissionInfoWithMetadata[] { if (!storedGatorPermissions) { - return gatorPermissionsMap; + return []; } - for (const storedGatorPermission of storedGatorPermissions) { - const { - permissionResponse: { - permission: { type: permissionType }, - chainId, - }, - } = storedGatorPermission; - - const isPermissionTypeKnown = Object.prototype.hasOwnProperty.call( - gatorPermissionsMap, - permissionType, - ); - - const permissionTypeKey = isPermissionTypeKnown - ? (permissionType as keyof GatorPermissionsMap) - : 'other'; - - type PermissionsMapElementArray = - GatorPermissionsMap[typeof permissionTypeKey][typeof chainId]; - - gatorPermissionsMap[permissionTypeKey][chainId] = [ - ...(gatorPermissionsMap[permissionTypeKey][chainId] || []), - this.#sanitizeStoredGatorPermission(storedGatorPermission), - ] as PermissionsMapElementArray; - } - - return gatorPermissionsMap; - } - - /** - * Gets the gator permissions map from the state. - * - * @returns The gator permissions map. - */ - get gatorPermissionsMap(): GatorPermissionsMap { - return deserializeGatorPermissionsMap( - this.state.gatorPermissionsMapSerialized, + return storedGatorPermissions.map((storedPermission) => + this.#storedPermissionToPermissionInfo(storedPermission), ); } /** - * Gets the gator permissions provider snap id that is used to fetch gator permissions. + * Fetches granted permissions from the gator permissions provider Snap and updates state. + * If a sync is already in progress, returns the same promise. After the sync completes, + * the next call will perform a new sync. * - * @returns The gator permissions provider snap id. + * @returns A promise that resolves when the sync completes. All data is available via the controller's state. + * @throws {GatorPermissionsFetchError} If the gator permissions fetch fails. */ - get permissionsProviderSnapId(): SnapId { - return this.state.gatorPermissionsProviderSnapId; - } + public fetchAndUpdateGatorPermissions(): Promise { + if (this.#fetchAndUpdateGatorPermissionsPromise !== null) { + return this.#fetchAndUpdateGatorPermissionsPromise; + } - /** - * Enables gator permissions for the user. - */ - public async enableGatorPermissions(): Promise { - this.#setIsGatorPermissionsEnabled(true); - } + const performFetchAndUpdate = async (): Promise => { + try { + this.#setIsFetchingGatorPermissions(true); + + // Only ever fetch non-revoked permissions. Revoked permissions may be + // left in storage by the gator permissions snap, but we don't need to + // fetch them. + const params = { isRevoked: false }; + + const permissionsData = await executeSnapRpc< + StoredGatorPermission[] | null + >({ + messenger: this.messenger, + snapId: this.#gatorPermissionsProviderSnapId, + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + params, + }); - /** - * Clears the gator permissions map and disables the feature. - */ - public async disableGatorPermissions(): Promise { - this.update((state) => { - state.isGatorPermissionsEnabled = false; - state.gatorPermissionsMapSerialized = serializeGatorPermissionsMap( - createEmptyGatorPermissionsMap(), - ); - }); - } + const grantedPermissions = + this.#storedPermissionsToPermissionInfoWithMetadata(permissionsData); - /** - * Gets the pending revocations list. - * - * @returns The pending revocations list. - */ - get pendingRevocations(): { txId: string; permissionContext: Hex }[] { - return this.state.pendingRevocations; + this.update((state) => { + state.grantedPermissions = grantedPermissions; + state.lastSyncedTimestamp = Date.now(); + }); + } catch (error) { + controllerLog('Failed to fetch gator permissions', error); + throw new GatorPermissionsFetchError({ + message: 'Failed to fetch gator permissions', + cause: error as Error, + }); + } finally { + this.#setIsFetchingGatorPermissions(false); + this.#fetchAndUpdateGatorPermissionsPromise = null; + } + }; + + this.#fetchAndUpdateGatorPermissionsPromise = performFetchAndUpdate(); + + return this.#fetchAndUpdateGatorPermissionsPromise; } /** - * Fetches the gator permissions from profile sync and updates the state. + * Initializes the controller. Call once after construction to ensure the + * controller is ready for use. * - * @param params - Optional parameters to pass to the snap's getGrantedPermissions method. - * @returns A promise that resolves to the gator permissions map. - * @throws {GatorPermissionsFetchError} If the gator permissions fetch fails. + * @returns A promise that resolves when initialization is complete. */ - public async fetchAndUpdateGatorPermissions( - params?: Json, - ): Promise { - try { - this.#setIsFetchingGatorPermissions(true); - this.#assertGatorPermissionsEnabled(); - - const permissionsData = - await this.#handleSnapRequestToGatorPermissionsProvider({ - snapId: this.state.gatorPermissionsProviderSnapId, - params, - }); - - const gatorPermissionsMap = - this.#categorizePermissionsDataByTypeAndChainId(permissionsData); - - this.update((state) => { - state.gatorPermissionsMapSerialized = - serializeGatorPermissionsMap(gatorPermissionsMap); - }); - - return gatorPermissionsMap; - } catch (error) { - controllerLog('Failed to fetch gator permissions', error); - throw new GatorPermissionsFetchError({ - message: 'Failed to fetch gator permissions', - cause: error as Error, - }); - } finally { - this.#setIsFetchingGatorPermissions(false); + public async initialize(): Promise { + const currentTime = Date.now(); + const millisecondsSinceLastSync = + currentTime - this.state.lastSyncedTimestamp; + + // Sync only when we have no data or data is stale, to avoid excessive startup + // queries while still avoiding showing stale data while a refresh runs. + if ( + this.state.lastSyncedTimestamp === -1 || + millisecondsSinceLastSync > this.#maxSyncIntervalMs + ) { + await this.fetchAndUpdateGatorPermissions(); } } @@ -666,7 +574,7 @@ export default class GatorPermissionsController extends BaseController< }; delegation: DelegationDetails; }): DecodedPermission { - if (origin !== this.permissionsProviderSnapId) { + if (origin !== this.#gatorPermissionsProviderSnapId) { throw new OriginNotAllowedError({ origin }); } @@ -715,7 +623,6 @@ export default class GatorPermissionsController extends BaseController< * * @param revocationParams - The revocation parameters containing the permission context. * @returns A promise that resolves when the revocation is submitted successfully. - * @throws {GatorPermissionsNotEnabledError} If the gator permissions are not enabled. * @throws {GatorPermissionsProviderError} If the snap request fails. */ public async submitRevocation( @@ -725,10 +632,8 @@ export default class GatorPermissionsController extends BaseController< permissionContext: revocationParams.permissionContext, }); - this.#assertGatorPermissionsEnabled(); - const snapRequest = { - snapId: this.state.gatorPermissionsProviderSnapId, + snapId: this.#gatorPermissionsProviderSnapId, origin: 'metamask', handler: HandlerType.OnRpcRequest, request: { @@ -746,7 +651,7 @@ export default class GatorPermissionsController extends BaseController< ); // Refresh list first (permission removed from list) - await this.fetchAndUpdateGatorPermissions({ isRevoked: false }); + await this.fetchAndUpdateGatorPermissions(); controllerLog('Successfully submitted revocation', { permissionContext: revocationParams.permissionContext, @@ -815,8 +720,6 @@ export default class GatorPermissionsController extends BaseController< permissionContext, }); - this.#assertGatorPermissionsEnabled(); - type PendingRevocationHandlers = { approved?: ( ...args: TransactionControllerTransactionApprovedEvent['payload'] @@ -848,15 +751,13 @@ export default class GatorPermissionsController extends BaseController< // Helper to refresh permissions after transaction state change const refreshPermissions = (context: string): void => { - this.fetchAndUpdateGatorPermissions({ isRevoked: false }).catch( - (error) => { - controllerLog(`Failed to refresh permissions after ${context}`, { - txId, - permissionContext, - error, - }); - }, - ); + this.fetchAndUpdateGatorPermissions().catch((error) => { + controllerLog(`Failed to refresh permissions after ${context}`, { + txId, + permissionContext, + error, + }); + }); }; // Helper to unsubscribe from approval/rejection events after decision is made @@ -1066,12 +967,9 @@ export default class GatorPermissionsController extends BaseController< * * @param params - The revocation parameters containing the permission context. * @returns A promise that resolves when the revocation is submitted successfully. - * @throws {GatorPermissionsNotEnabledError} If the gator permissions are not enabled. * @throws {GatorPermissionsProviderError} If the snap request fails. */ public async submitDirectRevocation(params: RevocationParams): Promise { - this.#assertGatorPermissionsEnabled(); - // Use a placeholder txId that doesn't conflict with real transaction IDs const placeholderTxId = `no-tx-${params.permissionContext}`; @@ -1092,10 +990,12 @@ export default class GatorPermissionsController extends BaseController< * @returns `true` if the permission context is pending revocation, `false` otherwise. */ public isPendingRevocation(permissionContext: Hex): boolean { + const requestedPermissionContextLowercase = permissionContext.toLowerCase(); + return this.state.pendingRevocations.some( (pendingRevocation) => pendingRevocation.permissionContext.toLowerCase() === - permissionContext.toLowerCase(), + requestedPermissionContextLowercase, ); } } diff --git a/packages/gator-permissions-controller/src/errors.ts b/packages/gator-permissions-controller/src/errors.ts index 137a2585665..4bd806742f7 100644 --- a/packages/gator-permissions-controller/src/errors.ts +++ b/packages/gator-permissions-controller/src/errors.ts @@ -33,38 +33,6 @@ export class GatorPermissionsFetchError extends GatorPermissionsControllerError } } -export class GatorPermissionsMapSerializationError extends GatorPermissionsControllerError { - data: unknown; - - constructor({ - cause, - message, - data, - }: { - cause: Error; - message: string; - data?: unknown; - }) { - super({ - cause, - message, - code: GatorPermissionsControllerErrorCode.GatorPermissionsMapSerializationError, - }); - - this.data = data; - } -} - -export class GatorPermissionsNotEnabledError extends GatorPermissionsControllerError { - constructor() { - super({ - cause: new Error('Gator permissions are not enabled'), - message: 'Gator permissions are not enabled', - code: GatorPermissionsControllerErrorCode.GatorPermissionsNotEnabled, - }); - } -} - export class GatorPermissionsProviderError extends GatorPermissionsControllerError { constructor({ cause, diff --git a/packages/gator-permissions-controller/src/index.ts b/packages/gator-permissions-controller/src/index.ts index b34a787d719..5c628f0ddf7 100644 --- a/packages/gator-permissions-controller/src/index.ts +++ b/packages/gator-permissions-controller/src/index.ts @@ -1,16 +1,11 @@ export { default as GatorPermissionsController } from './GatorPermissionsController'; -export { - serializeGatorPermissionsMap, - deserializeGatorPermissionsMap, -} from './utils'; export type { GatorPermissionsControllerState, + GatorPermissionsControllerConfig, GatorPermissionsControllerMessenger, GatorPermissionsControllerGetStateAction, GatorPermissionsControllerDecodePermissionFromPermissionContextForOriginAction, GatorPermissionsControllerFetchAndUpdateGatorPermissionsAction, - GatorPermissionsControllerEnableGatorPermissionsAction, - GatorPermissionsControllerDisableGatorPermissionsAction, GatorPermissionsControllerSubmitRevocationAction, GatorPermissionsControllerAddPendingRevocationAction, GatorPermissionsControllerSubmitDirectRevocationAction, @@ -24,20 +19,15 @@ export { DELEGATION_FRAMEWORK_VERSION } from './constants'; export type { GatorPermissionsControllerErrorCode, GatorPermissionsSnapRpcMethod, - CustomPermission, - PermissionTypesWithCustom, PermissionRequest, PermissionResponse, - PermissionResponseSanitized, + PermissionInfo, StoredGatorPermission, - StoredGatorPermissionSanitized, - GatorPermissionsMap, - SupportedGatorPermissionType, - GatorPermissionsMapByPermissionType, - GatorPermissionsListByPermissionTypeAndChainId, + PermissionInfoWithMetadata, DelegationDetails, RevocationParams, RevocationMetadata, + SupportedPermissionType, } from './types'; export type { diff --git a/packages/gator-permissions-controller/src/test/errors.test.ts b/packages/gator-permissions-controller/src/test/errors.test.ts new file mode 100644 index 00000000000..1da2d01944a --- /dev/null +++ b/packages/gator-permissions-controller/src/test/errors.test.ts @@ -0,0 +1,104 @@ +import { + GatorPermissionsControllerError, + GatorPermissionsFetchError, + GatorPermissionsProviderError, + OriginNotAllowedError, + PermissionDecodingError, +} from '../errors'; +import { + GatorPermissionsControllerErrorCode, + GatorPermissionsSnapRpcMethod, +} from '../types'; + +describe('errors', () => { + describe('GatorPermissionsControllerError', () => { + it('is extended by subclasses and sets message, cause, and code', () => { + const cause = new Error('root cause'); + const error = new GatorPermissionsFetchError({ + cause, + message: 'Fetch failed', + }); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(GatorPermissionsControllerError); + expect(error).toBeInstanceOf(GatorPermissionsFetchError); + expect(error.message).toBe('Fetch failed'); + expect(error.cause).toBe(cause); + expect(error.code).toBe( + GatorPermissionsControllerErrorCode.GatorPermissionsFetchError, + ); + }); + }); + + describe('GatorPermissionsFetchError', () => { + it('constructs with cause and message and sets correct code', () => { + const cause = new Error('network error'); + const error = new GatorPermissionsFetchError({ + cause, + message: 'Failed to fetch gator permissions', + }); + + expect(error.message).toBe('Failed to fetch gator permissions'); + expect(error.cause).toBe(cause); + expect(error.code).toBe( + GatorPermissionsControllerErrorCode.GatorPermissionsFetchError, + ); + }); + }); + + describe('GatorPermissionsProviderError', () => { + it('constructs with cause and method and builds message from method', () => { + const cause = new Error('Snap threw'); + const method = + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions; + const error = new GatorPermissionsProviderError({ cause, method }); + + expect(error.message).toBe( + `Failed to handle snap request to gator permissions provider for method ${method}`, + ); + expect(error.cause).toBe(cause); + expect(error.code).toBe( + GatorPermissionsControllerErrorCode.GatorPermissionsProviderError, + ); + }); + + it('includes submitRevocation method in message when that method fails', () => { + const cause = new Error('Snap rejected'); + const method = + GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation; + const error = new GatorPermissionsProviderError({ cause, method }); + + expect(error.message).toContain(method); + expect(error.code).toBe( + GatorPermissionsControllerErrorCode.GatorPermissionsProviderError, + ); + }); + }); + + describe('OriginNotAllowedError', () => { + it('constructs with origin and builds message and cause', () => { + const origin = 'https://evil.com'; + const error = new OriginNotAllowedError({ origin }); + + expect(error.message).toBe(`Origin ${origin} not allowed`); + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause.message).toBe(`Origin ${origin} not allowed`); + expect(error.code).toBe( + GatorPermissionsControllerErrorCode.OriginNotAllowedError, + ); + }); + }); + + describe('PermissionDecodingError', () => { + it('constructs with cause and sets fixed message and code', () => { + const cause = new Error('Invalid caveat format'); + const error = new PermissionDecodingError({ cause }); + + expect(error.message).toBe('Failed to decode permission'); + expect(error.cause).toBe(cause); + expect(error.code).toBe( + GatorPermissionsControllerErrorCode.PermissionDecodingError, + ); + }); + }); +}); diff --git a/packages/gator-permissions-controller/src/test/mock.test.ts b/packages/gator-permissions-controller/src/test/mock.test.ts index 35e87cf0e84..65e965ed921 100644 --- a/packages/gator-permissions-controller/src/test/mock.test.ts +++ b/packages/gator-permissions-controller/src/test/mock.test.ts @@ -9,29 +9,18 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { nativeTokenPeriodic: 1, erc20TokenStream: 3, erc20TokenPeriodic: 1, - custom: { - count: 2, - data: [ - { customField1: 'value1', customField2: 123 }, - { customField3: 'value3', customField4: true }, - ], - }, }, '0x5': { nativeTokenStream: 1, nativeTokenPeriodic: 2, erc20TokenStream: 1, erc20TokenPeriodic: 2, - custom: { - count: 1, - data: [{ customField5: 'value5' }], - }, }, }; const result = mockGatorPermissionsStorageEntriesFactory(config); - expect(result).toHaveLength(16); + expect(result).toHaveLength(13); // Check that all entries have the correct chainId const chainIds = result.map((entry) => entry.permissionResponse.chainId); @@ -46,16 +35,12 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { nativeTokenPeriodic: 1, erc20TokenStream: 1, erc20TokenPeriodic: 1, - custom: { - count: 1, - data: [{ testField: 'testValue' }], - }, }, }; const result = mockGatorPermissionsStorageEntriesFactory(config); - expect(result).toHaveLength(5); + expect(result).toHaveLength(4); // Check native-token-stream permission const nativeTokenStreamEntry = result.find( @@ -124,17 +109,6 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { justification: 'This is a very important request for streaming allowance for some very important thing', }); - - // Check custom permission - const customEntry = result.find( - (entry) => entry.permissionResponse.permission.type === 'custom', - ); - expect(customEntry).toBeDefined(); - expect(customEntry?.permissionResponse.permission.data).toMatchObject({ - justification: - 'This is a very important request for streaming allowance for some very important thing', - testField: 'testValue', - }); }); it('should handle empty counts for all permission types', () => { @@ -144,10 +118,6 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { nativeTokenPeriodic: 0, erc20TokenStream: 0, erc20TokenPeriodic: 0, - custom: { - count: 0, - data: [], - }, }, }; @@ -163,30 +133,18 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { nativeTokenPeriodic: 0, erc20TokenStream: 0, erc20TokenPeriodic: 0, - custom: { - count: 0, - data: [], - }, }, '0x5': { nativeTokenStream: 0, nativeTokenPeriodic: 1, erc20TokenStream: 0, erc20TokenPeriodic: 0, - custom: { - count: 0, - data: [], - }, }, '0xa': { nativeTokenStream: 0, nativeTokenPeriodic: 0, erc20TokenStream: 1, erc20TokenPeriodic: 0, - custom: { - count: 0, - data: [], - }, }, }; @@ -223,77 +181,6 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { ); }); - it('should handle custom permissions with different data', () => { - const config: MockGatorPermissionsStorageEntriesConfig = { - '0x1': { - nativeTokenStream: 0, - nativeTokenPeriodic: 0, - erc20TokenStream: 0, - erc20TokenPeriodic: 0, - custom: { - count: 3, - data: [ - { field1: 'value1', number1: 123 }, - { field2: 'value2', boolean1: true }, - { field3: 'value3', object1: { nested: 'value' } }, - ], - }, - }, - }; - - const result = mockGatorPermissionsStorageEntriesFactory(config); - - expect(result).toHaveLength(3); - - // Check that all entries are custom permissions - const permissionTypes = result.map( - (entry) => entry.permissionResponse.permission.type, - ); - expect(permissionTypes.every((type) => type === 'custom')).toBe(true); - - // Check that each entry has the correct custom data - const customData = result.map( - (entry) => entry.permissionResponse.permission.data, - ); - expect(customData[0]).toMatchObject({ - justification: - 'This is a very important request for streaming allowance for some very important thing', - field1: 'value1', - number1: 123, - }); - expect(customData[1]).toMatchObject({ - justification: - 'This is a very important request for streaming allowance for some very important thing', - field2: 'value2', - boolean1: true, - }); - expect(customData[2]).toMatchObject({ - justification: - 'This is a very important request for streaming allowance for some very important thing', - field3: 'value3', - object1: { nested: 'value' }, - }); - }); - - it('should throw error when custom count and data length mismatch', () => { - const config: MockGatorPermissionsStorageEntriesConfig = { - '0x1': { - nativeTokenStream: 0, - nativeTokenPeriodic: 0, - erc20TokenStream: 0, - erc20TokenPeriodic: 0, - custom: { - count: 2, - data: [{ field1: 'value1' }], - }, - }, - }; - - expect(() => mockGatorPermissionsStorageEntriesFactory(config)).toThrow( - 'Custom permission count and data length mismatch', - ); - }); - it('should handle complex configuration with multiple chain IDs and permission types', () => { const config: MockGatorPermissionsStorageEntriesConfig = { '0x1': { @@ -301,34 +188,26 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { nativeTokenPeriodic: 1, erc20TokenStream: 1, erc20TokenPeriodic: 2, - custom: { - count: 1, - data: [{ complexField: { nested: { deep: 'value' } } }], - }, }, '0x5': { nativeTokenStream: 1, nativeTokenPeriodic: 3, erc20TokenStream: 2, erc20TokenPeriodic: 1, - custom: { - count: 2, - data: [{ arrayField: [1, 2, 3] }, { nullField: null }], - }, }, }; const result = mockGatorPermissionsStorageEntriesFactory(config); - // Total expected entries - expect(result).toHaveLength(16); + // Total expected entries: 0x1: 2+1+1+2 = 6, 0x5: 1+3+2+1 = 7 + expect(result).toHaveLength(13); // Verify chain IDs are correct const chainIds = result.map((entry) => entry.permissionResponse.chainId); const chain0x1Count = chainIds.filter((id) => id === '0x1').length; const chain0x5Count = chainIds.filter((id) => id === '0x5').length; - expect(chain0x1Count).toBe(7); - expect(chain0x5Count).toBe(9); + expect(chain0x1Count).toBe(6); + expect(chain0x5Count).toBe(7); // Verify permission types are distributed correctly const permissionTypes = result.map( @@ -346,14 +225,10 @@ describe('mockGatorPermissionsStorageEntriesFactory', () => { const erc20TokenPeriodicCount = permissionTypes.filter( (type) => type === 'erc20-token-periodic', ).length; - const customCount = permissionTypes.filter( - (type) => type === 'custom', - ).length; expect(nativeTokenStreamCount).toBe(3); expect(nativeTokenPeriodicCount).toBe(4); expect(erc20TokenStreamCount).toBe(3); expect(erc20TokenPeriodicCount).toBe(3); - expect(customCount).toBe(3); }); }); diff --git a/packages/gator-permissions-controller/src/test/mocks.ts b/packages/gator-permissions-controller/src/test/mocks.ts index afe9e8dca9a..7e62f09f6b3 100644 --- a/packages/gator-permissions-controller/src/test/mocks.ts +++ b/packages/gator-permissions-controller/src/test/mocks.ts @@ -6,12 +6,14 @@ import type { } from '@metamask/7715-permission-types'; import type { Hex } from '@metamask/utils'; -import type { - CustomPermission, - PermissionTypesWithCustom, - StoredGatorPermission, -} from '../types'; +import type { StoredGatorPermission } from '../types'; +/** + * Mock stored gator permission: native-token-stream (as returned by the Snap). + * + * @param chainId - The chain ID of the permission. + * @returns Mock stored gator permission: native-token-stream. + */ export const mockNativeTokenStreamStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ @@ -43,6 +45,12 @@ export const mockNativeTokenStreamStorageEntry = ( siteOrigin: 'http://localhost:8000', }); +/** + * Mock stored gator permission: native-token-periodic (as returned by the Snap). + * + * @param chainId - The chain ID of the permission. + * @returns Mock stored gator permission: native-token-periodic. + */ export const mockNativeTokenPeriodicStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ @@ -73,6 +81,12 @@ export const mockNativeTokenPeriodicStorageEntry = ( siteOrigin: 'http://localhost:8000', }); +/** + * Mock stored gator permission: erc20-token-stream (as returned by the Snap). + * + * @param chainId - The chain ID of the permission. + * @returns Mock stored gator permission: erc20-token-stream. + */ export const mockErc20TokenStreamStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ @@ -105,6 +119,12 @@ export const mockErc20TokenStreamStorageEntry = ( siteOrigin: 'http://localhost:8000', }); +/** + * Mock stored gator permission: erc20-token-periodic (as returned by the Snap). + * + * @param chainId - The chain ID of the permission. + * @returns Mock stored gator permission: erc20-token-periodic. + */ export const mockErc20TokenPeriodicStorageEntry = ( chainId: Hex, ): StoredGatorPermission => ({ @@ -136,79 +156,33 @@ export const mockErc20TokenPeriodicStorageEntry = ( siteOrigin: 'http://localhost:8000', }); -export const mockCustomPermissionStorageEntry = ( - chainId: Hex, - data: Record, -): StoredGatorPermission => ({ - permissionResponse: { - chainId, - from: '0xB68c70159E9892DdF5659ec42ff9BD2bbC23e778', - to: '0x4f71DA06987BfeDE90aF0b33E1e3e4ffDCEE7a63', - permission: { - type: 'custom', - isAdjustmentAllowed: true, - data: { - justification: - 'This is a very important request for streaming allowance for some very important thing', - ...data, - }, - }, - context: '0x00000000', - dependencies: [ - { - factory: '0x69Aa2f9fe1572F1B640E1bbc512f5c3a734fc77c', - factoryData: '0x0000000', - }, - ], - delegationManager: '0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3', - }, - siteOrigin: 'http://localhost:8000', -}); - +/** + * Config for mock stored gator permissions: per chainId, how many of each permission type to create. + */ export type MockGatorPermissionsStorageEntriesConfig = { [chainId: string]: { nativeTokenStream: number; nativeTokenPeriodic: number; erc20TokenStream: number; erc20TokenPeriodic: number; - custom: { - count: number; - data: Record[]; - }; }; }; /** - * Creates a mock gator permissions storage entry + * Creates mock stored gator permissions as returned by the gator permissions provider Snap. * - * @param config - The config for the mock gator permissions storage entries. - * @returns Mock gator permissions storage entry - */ -/** - * Creates mock gator permissions storage entries with unique expiry times - * - * @param config - The config for the mock gator permissions storage entries. - * @returns Mock gator permissions storage entries + * @param config - Per-chain counts for each permission type. + * @returns Array of {@link StoredGatorPermission} entries. */ export function mockGatorPermissionsStorageEntriesFactory( config: MockGatorPermissionsStorageEntriesConfig, -): StoredGatorPermission[] { - const result: StoredGatorPermission[] = []; +): StoredGatorPermission[] { + const result: StoredGatorPermission[] = []; Object.entries(config).forEach(([chainId, counts]) => { - if (counts.custom.count !== counts.custom.data.length) { - throw new Error('Custom permission count and data length mismatch'); - } - - /** - * Creates a number of entries with unique expiry times - * - * @param count - The number of entries to create. - * @param createEntry - The function to create an entry. - */ const createEntries = ( count: number, - createEntry: () => StoredGatorPermission, + createEntry: () => StoredGatorPermission, ): void => { for (let i = 0; i < count; i++) { const entry = createEntry(); @@ -231,15 +205,6 @@ export function mockGatorPermissionsStorageEntriesFactory( createEntries(counts.erc20TokenPeriodic, () => mockErc20TokenPeriodicStorageEntry(chainId as Hex), ); - - // Create custom entries - for (let i = 0; i < counts.custom.count; i++) { - const entry = mockCustomPermissionStorageEntry( - chainId as Hex, - counts.custom.data[i], - ); - result.push(entry); - } }); return result; diff --git a/packages/gator-permissions-controller/src/types.ts b/packages/gator-permissions-controller/src/types.ts index 47fc0b0d94a..c1daeaa85cc 100644 --- a/packages/gator-permissions-controller/src/types.ts +++ b/packages/gator-permissions-controller/src/types.ts @@ -1,14 +1,4 @@ -import type { - PermissionTypes, - BasePermission, - NativeTokenStreamPermission, - NativeTokenPeriodicPermission, - Erc20TokenStreamPermission, - Erc20TokenPeriodicPermission, - Rule, - MetaMaskBasePermissionData, - Erc20TokenRevocationPermission, -} from '@metamask/7715-permission-types'; +import type { PermissionTypes, Rule } from '@metamask/7715-permission-types'; import type { Delegation } from '@metamask/delegation-core'; import type { Hex } from '@metamask/utils'; @@ -17,9 +7,7 @@ import type { Hex } from '@metamask/utils'; */ export enum GatorPermissionsControllerErrorCode { GatorPermissionsFetchError = 'gator-permissions-fetch-error', - GatorPermissionsNotEnabled = 'gator-permissions-not-enabled', GatorPermissionsProviderError = 'gator-permissions-provider-error', - GatorPermissionsMapSerializationError = 'gator-permissions-map-serialization-error', PermissionDecodingError = 'permission-decoding-error', OriginNotAllowedError = 'origin-not-allowed-error', } @@ -39,25 +27,11 @@ export enum GatorPermissionsSnapRpcMethod { } /** - * Represents a custom permission that are not of the standard ERC-7715 permission types. - */ -export type CustomPermission = BasePermission & { - type: 'custom'; - data: MetaMaskBasePermissionData & Record; -}; - -/** - * Represents the type of the ERC-7715 permissions that can be granted including custom permissions. - */ -export type PermissionTypesWithCustom = PermissionTypes | CustomPermission; - -/** - * Represents a ERC-7715 permission request. + * Represents an ERC-7715 permission request. * - * @template to - The type of the signer provided, either an AccountSigner or WalletSigner. - * @template Permission - The type of the permission provided. + * @template TPermission - The type of the permission provided. */ -export type PermissionRequest = { +export type PermissionRequest = { /** * hex-encoding of uint256 defined the chain with EIP-155 */ @@ -84,11 +58,11 @@ export type PermissionRequest = { }; /** - * Represents a ERC-7715 permission response. + * Represents an ERC-7715 permission response. * - * @template Permission - The type of the permission provided. + * @template TPermission - The type of the permission provided. */ -export type PermissionResponse = +export type PermissionResponse = PermissionRequest & { /** * Is a catch-all to identify a permission for revoking permissions or submitting @@ -115,22 +89,12 @@ export type PermissionResponse = }; /** - * Represents a sanitized version of the PermissionResponse type. - * Internal fields (dependencies, to) are removed + * Represents a gator ERC-7715 permission entry retrieved from gator permissions snap. * - * @template Permission - The type of the permission provided. - */ -export type PermissionResponseSanitized< - TPermission extends PermissionTypesWithCustom, -> = Omit, 'dependencies' | 'to'>; - -/** - * Represents a gator ERC-7715 granted(ie. signed by a user's account) permission entry that is stored in profile sync. - * - * @template Permission - The type of the permission provided + * @template TPermission - The type of the permission provided. */ export type StoredGatorPermission< - TPermission extends PermissionTypesWithCustom, + TPermission extends PermissionTypes = PermissionTypes, > = { permissionResponse: PermissionResponse; siteOrigin: string; @@ -138,73 +102,31 @@ export type StoredGatorPermission< }; /** - * Represents a sanitized version of the StoredGatorPermission type. Some fields have been removed but the fields are still present in profile sync. + * Permission response with internal fields (dependencies, to) removed. + * Used when exposing permission data to the client/UI. * - * @template Permission - The type of the permission provided. + * @template TPermission - The type of the permission provided. */ -export type StoredGatorPermissionSanitized< - TPermission extends PermissionTypesWithCustom, -> = { - permissionResponse: PermissionResponseSanitized; - siteOrigin: string; - revocationMetadata?: RevocationMetadata; -}; +export type PermissionInfo = Omit< + PermissionResponse, + 'dependencies' | 'to' +>; /** - * Represents a map of gator permissions by chainId and permission type. + * Granted permission with metadata (siteOrigin, optional revocationMetadata). + * + * @template TPermission - The type of the permission provided. */ -export type GatorPermissionsMap = { - 'erc20-token-revocation': { - [ - chainId: Hex - ]: StoredGatorPermissionSanitized[]; - }; - 'native-token-stream': { - [ - chainId: Hex - ]: StoredGatorPermissionSanitized[]; - }; - 'native-token-periodic': { - [ - chainId: Hex - ]: StoredGatorPermissionSanitized[]; - }; - 'erc20-token-stream': { - [ - chainId: Hex - ]: StoredGatorPermissionSanitized[]; - }; - 'erc20-token-periodic': { - [ - chainId: Hex - ]: StoredGatorPermissionSanitized[]; - }; - other: { - [chainId: Hex]: StoredGatorPermissionSanitized[]; - }; +export type PermissionInfoWithMetadata< + TPermission extends PermissionTypes = PermissionTypes, +> = { + permissionResponse: PermissionInfo; + siteOrigin: string; + revocationMetadata?: RevocationMetadata; }; /** - * Represents the supported permission type(e.g. 'native-token-stream', 'native-token-periodic', 'erc20-token-stream', 'erc20-token-periodic') of the gator permissions map. - */ -export type SupportedGatorPermissionType = keyof GatorPermissionsMap; - -/** - * Represents a map of gator permissions for a given permission type with key of chainId. The value being an array of gator permissions for that chainId. - */ -export type GatorPermissionsMapByPermissionType< - TPermissionType extends SupportedGatorPermissionType, -> = GatorPermissionsMap[TPermissionType]; - -/** - * Represents an array of gator permissions for a given permission type and chainId. - */ -export type GatorPermissionsListByPermissionTypeAndChainId< - TPermissionType extends SupportedGatorPermissionType, -> = GatorPermissionsMap[TPermissionType][Hex]; - -/** - * Represents the details of a delegation, that are required to decode a permission. + * Delegation fields required to decode a permission (caveats, delegator, delegate, authority). */ export type DelegationDetails = Pick< Delegation, @@ -212,17 +134,17 @@ export type DelegationDetails = Pick< >; /** - * Represents the metadata for confirmed transaction revocation. + * Metadata for a confirmed revocation (e.g. when and how it was recorded). */ export type RevocationMetadata = { - // The timestamp at which the revocation was recorded in storage. + /** Timestamp when the revocation was recorded in storage. */ recordedAt: number; - // The hash of the transaction that was used to revoke the permission. Optional because we might not have submitted the transaction ourselves. + /** Hash of the revocation transaction, if we submitted it. */ txHash?: Hex | undefined; }; /** - * Parameters for the `permissionsProvider_submitRevocation` method + * Parameters for the permissions provider Snap's submitRevocation RPC. */ export type RevocationParams = { /** @@ -237,7 +159,7 @@ export type RevocationParams = { }; /** - * Represents the parameters for adding a pending revocation. + * Parameters for adding a pending revocation (tracked until the revocation tx is confirmed). */ export type PendingRevocationParams = { /** @@ -249,3 +171,8 @@ export type PendingRevocationParams = { */ permissionContext: Hex; }; + +/** + * Permission type identifier: the `type` field of standard ERC-7715 permissions. + */ +export type SupportedPermissionType = PermissionTypes['type']; diff --git a/packages/gator-permissions-controller/src/utils.test.ts b/packages/gator-permissions-controller/src/utils.test.ts index 2d586db9f92..d97067d5814 100644 --- a/packages/gator-permissions-controller/src/utils.test.ts +++ b/packages/gator-permissions-controller/src/utils.test.ts @@ -1,71 +1,122 @@ -import type { GatorPermissionsMap } from './types'; -import { - deserializeGatorPermissionsMap, - serializeGatorPermissionsMap, -} from './utils'; - -const defaultGatorPermissionsMap: GatorPermissionsMap = { - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, -}; - -describe('utils - serializeGatorPermissionsMap() tests', () => { - it('serializes a gator permissions list to a string', () => { - const serializedGatorPermissionsMap = serializeGatorPermissionsMap( - defaultGatorPermissionsMap, - ); +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; + +import { GatorPermissionsProviderError } from './errors'; +import type { GatorPermissionsControllerMessenger } from './GatorPermissionsController'; +import { GatorPermissionsSnapRpcMethod } from './types'; +import { executeSnapRpc } from './utils'; + +describe('executeSnapRpc', () => { + const mockSnapId = 'npm:@metamask/test-snap' as SnapId; + + function createMockMessenger(): { call: jest.Mock } { + return { call: jest.fn() }; + } - expect(serializedGatorPermissionsMap).toStrictEqual( - JSON.stringify(defaultGatorPermissionsMap), + function getMessenger(mock: { + call: jest.Mock; + }): GatorPermissionsControllerMessenger { + return mock as unknown as GatorPermissionsControllerMessenger; + } + + it('calls SnapController:handleRequest with correct arguments and returns response', async () => { + const response = { result: [1, 2, 3] }; + const messenger = createMockMessenger(); + messenger.call.mockResolvedValue(response); + + const result = await executeSnapRpc({ + messenger: getMessenger(messenger), + snapId: mockSnapId, + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + }); + + expect(messenger.call).toHaveBeenCalledTimes(1); + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + expect.objectContaining({ + snapId: mockSnapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + }, + }), ); + expect(result).toStrictEqual(response); }); - it('throws an error when serialization fails', () => { - const gatorPermissionsMap = { - 'erc20-token-revocation': {}, - 'native-token-stream': {}, - 'native-token-periodic': {}, - 'erc20-token-stream': {}, - 'erc20-token-periodic': {}, - other: {}, - }; - - // explicitly cause serialization to fail - (gatorPermissionsMap as unknown as { toJSON: () => void }).toJSON = - (): void => { - throw new Error('Failed serialization'); - }; - - expect(() => { - serializeGatorPermissionsMap(gatorPermissionsMap); - }).toThrow('Failed to serialize gator permissions map'); - }); -}); + it('includes params in request when provided', async () => { + const params = { isRevoked: false }; + const messenger = createMockMessenger(); + messenger.call.mockResolvedValue(null); -describe('utils - deserializeGatorPermissionsMap() tests', () => { - it('deserializes a gator permissions list from a string', () => { - const serializedGatorPermissionsMap = serializeGatorPermissionsMap( - defaultGatorPermissionsMap, - ); + await executeSnapRpc({ + messenger: getMessenger(messenger), + snapId: mockSnapId, + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + params, + }); - const deserializedGatorPermissionsMap = deserializeGatorPermissionsMap( - serializedGatorPermissionsMap, + expect(messenger.call).toHaveBeenCalledWith( + 'SnapController:handleRequest', + { + snapId: mockSnapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + params, + }, + }, ); + }); - expect(deserializedGatorPermissionsMap).toStrictEqual( - defaultGatorPermissionsMap, - ); + it('omits params from request when not provided', async () => { + const messenger = createMockMessenger(); + messenger.call.mockResolvedValue(undefined); + + await executeSnapRpc({ + messenger: getMessenger(messenger), + snapId: mockSnapId, + method: GatorPermissionsSnapRpcMethod.PermissionProviderSubmitRevocation, + }); + + const callArgs = messenger.call.mock.calls[0][1]; + expect(callArgs.request).not.toHaveProperty('params'); }); - it('throws an error when deserialization fails', () => { - const invalidJson = '{"invalid": json}'; + it('throws GatorPermissionsProviderError when Snap request fails', async () => { + const cause = new Error('Snap not found'); + const messenger = createMockMessenger(); + messenger.call.mockRejectedValue(cause); + + await expect( + executeSnapRpc({ + messenger: getMessenger(messenger), + snapId: mockSnapId, + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + }), + ).rejects.toThrow(GatorPermissionsProviderError); - expect(() => { - deserializeGatorPermissionsMap(invalidJson); - }).toThrow('Failed to deserialize gator permissions map'); + await expect( + executeSnapRpc({ + messenger: getMessenger(messenger), + snapId: mockSnapId, + method: + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + }), + ).rejects.toMatchObject({ + cause, + message: expect.stringContaining( + GatorPermissionsSnapRpcMethod.PermissionProviderGetGrantedPermissions, + ), + }); }); }); diff --git a/packages/gator-permissions-controller/src/utils.ts b/packages/gator-permissions-controller/src/utils.ts index 50ee6851f40..d0de1d7c229 100644 --- a/packages/gator-permissions-controller/src/utils.ts +++ b/packages/gator-permissions-controller/src/utils.ts @@ -1,45 +1,51 @@ -import { GatorPermissionsMapSerializationError } from './errors'; +import type { SnapId } from '@metamask/snaps-sdk'; +import { HandlerType } from '@metamask/snaps-utils'; +import type { Json } from '@metamask/utils'; + +import { GatorPermissionsProviderError } from './errors'; +import { GatorPermissionsControllerMessenger } from './GatorPermissionsController'; import { utilsLog } from './logger'; -import type { GatorPermissionsMap } from './types'; +import type { GatorPermissionsSnapRpcMethod } from './types'; /** - * Serializes a gator permissions map to a string. + * Executes an RPC request against a Snap and returns the typed response. * - * @param gatorPermissionsMap - The gator permissions map to serialize. - * @returns The serialized gator permissions map. + * @param params - The parameters for the request. + * @param params.messenger - Messenger that supports SnapController:handleRequest. + * @param params.snapId - The Snap ID to target. + * @param params.method - The RPC method name (e.g. permissionsProvider_getGrantedPermissions). + * @param params.params - Optional JSON-serializable params for the method. + * @returns A promise that resolves with the Snap's response (typed by caller). + * @throws {GatorPermissionsProviderError} If the Snap request fails. */ -export function serializeGatorPermissionsMap( - gatorPermissionsMap: GatorPermissionsMap, -): string { +export async function executeSnapRpc({ + messenger, + snapId, + method, + params, +}: { + messenger: GatorPermissionsControllerMessenger; + snapId: SnapId; + method: GatorPermissionsSnapRpcMethod | string; + params?: Json; +}): Promise { try { - return JSON.stringify(gatorPermissionsMap); - } catch (error) { - utilsLog('Failed to serialize gator permissions map', error); - throw new GatorPermissionsMapSerializationError({ - cause: error as Error, - message: 'Failed to serialize gator permissions map', - data: gatorPermissionsMap, + const response = await messenger.call('SnapController:handleRequest', { + snapId, + origin: 'metamask', + handler: HandlerType.OnRpcRequest, + request: { + jsonrpc: '2.0', + method, + ...(params !== undefined && { params }), + }, }); - } -} - -/** - * Deserializes a gator permissions map from a string. - * - * @param gatorPermissionsMap - The gator permissions map to deserialize. - * @returns The deserialized gator permissions map. - */ -export function deserializeGatorPermissionsMap( - gatorPermissionsMap: string, -): GatorPermissionsMap { - try { - return JSON.parse(gatorPermissionsMap); + return response as TReturn; } catch (error) { - utilsLog('Failed to deserialize gator permissions map', error); - throw new GatorPermissionsMapSerializationError({ + utilsLog('Snap RPC request failed', { method, error }); + throw new GatorPermissionsProviderError({ + method: method as GatorPermissionsSnapRpcMethod, cause: error as Error, - message: 'Failed to deserialize gator permissions map', - data: gatorPermissionsMap, }); } } diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index c2f894b1fbb..1d4349c54ff 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -80,12 +80,12 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "jest-it-up": "^2.0.2", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typescript": "~5.3.3" }, "engines": { diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 69151ddb72a..ac4e8008419 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/json-rpc-engine', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "asV2Middleware", "createAsyncMiddleware", "createScaffoldMiddleware", diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts index 4195008be4f..a36b0ef1b99 100644 --- a/packages/json-rpc-engine/src/v2/index.test.ts +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/json-rpc-engine/v2', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports).sort()).toMatchInlineSnapshot(` - Array [ + [ "JsonRpcEngineError", "JsonRpcEngineV2", "JsonRpcServer", diff --git a/packages/json-rpc-engine/src/v2/utils.test.ts b/packages/json-rpc-engine/src/v2/utils.test.ts index 77fd05edd95..5a6c1bf8327 100644 --- a/packages/json-rpc-engine/src/v2/utils.test.ts +++ b/packages/json-rpc-engine/src/v2/utils.test.ts @@ -49,7 +49,7 @@ describe('utils', () => { it('stringifies a JSON object', () => { expect(stringify({ foo: 'bar' })).toMatchInlineSnapshot(` "{ - \\"foo\\": \\"bar\\" + "foo": "bar" }" `); }); diff --git a/packages/json-rpc-middleware-stream/package.json b/packages/json-rpc-middleware-stream/package.json index ef397be018c..ac5c6808ffb 100644 --- a/packages/json-rpc-middleware-stream/package.json +++ b/packages/json-rpc-middleware-stream/package.json @@ -56,14 +56,14 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/readable-stream": "^2.3.0", "deepmerge": "^4.2.2", "extension-port-stream": "^3.0.0", - "jest": "^27.5.1", + "jest": "^29.7.0", "jest-it-up": "^2.0.2", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "webextension-polyfill-ts": "^0.26.0" diff --git a/packages/keyring-controller/jest.environment.js b/packages/keyring-controller/jest.environment.js index 6af3a20c151..abe104a6a81 100644 --- a/packages/keyring-controller/jest.environment.js +++ b/packages/keyring-controller/jest.environment.js @@ -1,10 +1,10 @@ -const NodeEnvironment = require('jest-environment-node'); +const { TestEnvironment } = require('jest-environment-node'); /** * KeyringController depends on @noble/hashes, which as of 1.3.2 relies on the * Web Crypto API in Node and browsers. */ -class CustomTestEnvironment extends NodeEnvironment { +class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); if (typeof this.global.crypto === 'undefined') { diff --git a/packages/keyring-controller/package.json b/packages/keyring-controller/package.json index 05949cb6e84..7d75dc13e9f 100644 --- a/packages/keyring-controller/package.json +++ b/packages/keyring-controller/package.json @@ -73,13 +73,12 @@ "@metamask/keyring-utils": "^3.1.0", "@metamask/scure-bip39": "^2.1.1", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-node": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "uuid": "^8.3.2" diff --git a/packages/keyring-controller/src/KeyringController.test.ts b/packages/keyring-controller/src/KeyringController.test.ts index e0bd8947e71..9498f4efe78 100644 --- a/packages/keyring-controller/src/KeyringController.test.ts +++ b/packages/keyring-controller/src/KeyringController.test.ts @@ -23,7 +23,6 @@ import type { import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english'; import { bytesToHex, isValidHexAddress } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import sinon from 'sinon'; import { KeyringControllerErrorMessage } from './constants'; import { KeyringControllerError } from './errors'; @@ -135,7 +134,6 @@ function createVault(keyrings: SerializedKeyring[] = defaultKeyrings): string { describe('KeyringController', () => { afterEach(() => { - sinon.restore(); jest.resetAllMocks(); }); @@ -838,10 +836,10 @@ describe('KeyringController', () => { it('should emit KeyringController:lock event', async () => { await withController(async ({ controller, messenger }) => { - const listener = sinon.spy(); + const listener = jest.fn(); messenger.subscribe('KeyringController:lock', listener); await controller.setLocked(); - expect(listener.called).toBe(true); + expect(listener).toHaveBeenCalled(); }); }); @@ -1617,13 +1615,13 @@ describe('KeyringController', () => { AccountImportStrategy.privateKey, [privateKey], ); - const listener = sinon.spy(); + const listener = jest.fn(); messenger.subscribe('KeyringController:accountRemoved', listener); const removedAccount = '0x51253087e6f8358b5f10c0a94315d69db3357859'; await controller.removeAccount(removedAccount); - expect(listener.calledWith(removedAccount)).toBe(true); + expect(listener).toHaveBeenCalledWith(removedAccount); }); }); @@ -2785,10 +2783,10 @@ describe('KeyringController', () => { it('should emit KeyringController:unlock event', async () => { await withController(async ({ controller, messenger }) => { - const listener = sinon.spy(); + const listener = jest.fn(); messenger.subscribe('KeyringController:unlock', listener); await controller.submitPassword(password); - expect(listener.called).toBe(true); + expect(listener).toHaveBeenCalled(); }); }); @@ -3533,9 +3531,9 @@ describe('KeyringController', () => { describe('when wrong password is provided', () => { it('should throw an error', async () => { await withController(async ({ controller, encryptor }) => { - sinon - .stub(encryptor, 'decrypt') - .rejects(new Error('Incorrect password')); + jest + .spyOn(encryptor, 'decrypt') + .mockRejectedValue(new Error('Incorrect password')); await expect(controller.verifyPassword('12341234')).rejects.toThrow( 'Incorrect password', @@ -4386,7 +4384,7 @@ describe('KeyringController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "isUnlocked": false, } `); @@ -4406,9 +4404,9 @@ describe('KeyringController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "isUnlocked": false, - "keyrings": Array [], + "keyrings": [], } `); }, @@ -4427,8 +4425,8 @@ describe('KeyringController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "vault": "{\\"data\\":\\"{\\\\\\"tag\\\\\\":{\\\\\\"key\\\\\\":{\\\\\\"password\\\\\\":\\\\\\"password123\\\\\\",\\\\\\"salt\\\\\\":\\\\\\"salt\\\\\\"},\\\\\\"iv\\\\\\":\\\\\\"iv\\\\\\"},\\\\\\"value\\\\\\":[{\\\\\\"type\\\\\\":\\\\\\"HD Key Tree\\\\\\",\\\\\\"data\\\\\\":{\\\\\\"mnemonic\\\\\\":[119,97,114,114,105,111,114,32,108,97,110,103,117,97,103,101,32,106,111,107,101,32,98,111,110,117,115,32,117,110,102,97,105,114,32,97,114,116,105,115,116,32,107,97,110,103,97,114,111,111,32,99,105,114,99,108,101,32,101,120,112,97,110,100,32,104,111,112,101,32,109,105,100,100,108,101,32,103,97,117,103,101],\\\\\\"numberOfAccounts\\\\\\":1,\\\\\\"hdPath\\\\\\":\\\\\\"m/44'/60'/0'/0\\\\\\"},\\\\\\"metadata\\\\\\":{\\\\\\"id\\\\\\":\\\\\\"01JXEFM7DAX2VJ0YFR4ESNY3GQ\\\\\\",\\\\\\"name\\\\\\":\\\\\\"\\\\\\"}}]}\\",\\"iv\\":\\"iv\\",\\"salt\\":\\"salt\\"}", + { + "vault": "{"data":"{\\"tag\\":{\\"key\\":{\\"password\\":\\"password123\\",\\"salt\\":\\"salt\\"},\\"iv\\":\\"iv\\"},\\"value\\":[{\\"type\\":\\"HD Key Tree\\",\\"data\\":{\\"mnemonic\\":[119,97,114,114,105,111,114,32,108,97,110,103,117,97,103,101,32,106,111,107,101,32,98,111,110,117,115,32,117,110,102,97,105,114,32,97,114,116,105,115,116,32,107,97,110,103,97,114,111,111,32,99,105,114,99,108,101,32,101,120,112,97,110,100,32,104,111,112,101,32,109,105,100,100,108,101,32,103,97,117,103,101],\\"numberOfAccounts\\":1,\\"hdPath\\":\\"m/44'/60'/0'/0\\"},\\"metadata\\":{\\"id\\":\\"01JXEFM7DAX2VJ0YFR4ESNY3GQ\\",\\"name\\":\\"\\"}}]}","iv":"iv","salt":"salt"}", } `); }, @@ -4447,9 +4445,9 @@ describe('KeyringController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "isUnlocked": false, - "keyrings": Array [], + "keyrings": [], } `); }, diff --git a/packages/logging-controller/package.json b/packages/logging-controller/package.json index 87909bbd664..47912e90a23 100644 --- a/packages/logging-controller/package.json +++ b/packages/logging-controller/package.json @@ -56,11 +56,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/logging-controller/src/LoggingController.test.ts b/packages/logging-controller/src/LoggingController.test.ts index 43e3db71f1d..43fd99d7a7c 100644 --- a/packages/logging-controller/src/LoggingController.test.ts +++ b/packages/logging-controller/src/LoggingController.test.ts @@ -206,7 +206,7 @@ describe('LoggingController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -223,8 +223,8 @@ describe('LoggingController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "logs": Object {}, + { + "logs": {}, } `); }); @@ -243,8 +243,8 @@ describe('LoggingController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "logs": Object {}, + { + "logs": {}, } `); }); @@ -262,7 +262,7 @@ describe('LoggingController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/message-manager/package.json b/packages/message-manager/package.json index c6f462fb252..ad8a1895068 100644 --- a/packages/message-manager/package.json +++ b/packages/message-manager/package.json @@ -60,11 +60,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/message-manager/src/AbstractMessageManager.test.ts b/packages/message-manager/src/AbstractMessageManager.test.ts index 3801f27d306..bd12258372f 100644 --- a/packages/message-manager/src/AbstractMessageManager.test.ts +++ b/packages/message-manager/src/AbstractMessageManager.test.ts @@ -588,7 +588,7 @@ describe('AbstractTestManager', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -601,8 +601,8 @@ describe('AbstractTestManager', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "unapprovedMessages": Object {}, + { + "unapprovedMessages": {}, "unapprovedMessagesCount": 0, } `); @@ -617,7 +617,7 @@ describe('AbstractTestManager', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -630,8 +630,8 @@ describe('AbstractTestManager', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "unapprovedMessages": Object {}, + { + "unapprovedMessages": {}, "unapprovedMessagesCount": 0, } `); diff --git a/packages/messenger/package.json b/packages/messenger/package.json index 36eb92a55af..aae2dfd2a7d 100644 --- a/packages/messenger/package.json +++ b/packages/messenger/package.json @@ -50,13 +50,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "immer": "^9.0.6", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 64b50e9d99a..df0547199ec 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -1,14 +1,9 @@ import type { Patch } from 'immer'; -import sinon from 'sinon'; import { Messenger, MOCK_ANY_NAMESPACE } from './Messenger'; import type { MockAnyNamespace } from './Messenger'; describe('Messenger', () => { - afterEach(() => { - sinon.restore(); - }); - describe('registerActionHandler and call', () => { it('allows registering and calling an action handler', () => { type CountAction = { @@ -331,12 +326,12 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.publish('Fixture:message', 'hello'); - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); }); it('publishes event from different namespace using MOCK_ANY_NAMESPACE', () => { @@ -345,12 +340,12 @@ describe('Messenger', () => { namespace: MOCK_ANY_NAMESPACE, }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.publish('Fixture:message', 'hello'); - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); }); it('automatically delegates events to parent upon first publish', () => { @@ -368,12 +363,12 @@ describe('Messenger', () => { parent: parentMessenger, }); - const handler = sinon.stub(); + const handler = jest.fn(); parentMessenger.subscribe('Fixture:message', handler); messenger.publish('Fixture:message', 'hello'); - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); }); it('allows publishing multiple different events to subscriber', () => { @@ -384,18 +379,18 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const messageHandler = sinon.stub(); - const pingHandler = sinon.stub(); + const messageHandler = jest.fn(); + const pingHandler = jest.fn(); messenger.subscribe('Fixture:message', messageHandler); messenger.subscribe('Fixture:ping', pingHandler); messenger.publish('Fixture:message', 'hello'); messenger.publish('Fixture:ping'); - expect(messageHandler.calledWithExactly('hello')).toBe(true); - expect(messageHandler.callCount).toBe(1); - expect(pingHandler.calledWithExactly()).toBe(true); - expect(pingHandler.callCount).toBe(1); + expect(messageHandler).toHaveBeenCalledWith('hello'); + expect(messageHandler.mock.calls).toHaveLength(1); + expect(pingHandler).toHaveBeenCalledWith(); + expect(pingHandler.mock.calls).toHaveLength(1); }); it('publishes event with no payload to subscriber', () => { @@ -404,12 +399,12 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:ping', handler); messenger.publish('Fixture:ping'); - expect(handler.calledWithExactly()).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith(); + expect(handler.mock.calls).toHaveLength(1); }); it('publishes event with multiple payload parameters to subscriber', () => { @@ -421,12 +416,12 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.publish('Fixture:message', 'hello', 'there'); - expect(handler.calledWithExactly('hello', 'there')).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('hello', 'there'); + expect(handler.mock.calls).toHaveLength(1); }); it('publishes event once to subscriber even if subscribed multiple times', () => { @@ -435,13 +430,13 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.subscribe('Fixture:message', handler); messenger.publish('Fixture:message', 'hello'); - expect(handler.calledWithExactly('hello')).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('hello'); + expect(handler.mock.calls).toHaveLength(1); }); it('publishes event to many subscribers', () => { @@ -450,16 +445,16 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); + const handler1 = jest.fn(); + const handler2 = jest.fn(); messenger.subscribe('Fixture:message', handler1); messenger.subscribe('Fixture:message', handler2); messenger.publish('Fixture:message', 'hello'); - expect(handler1.calledWithExactly('hello')).toBe(true); - expect(handler1.callCount).toBe(1); - expect(handler2.calledWithExactly('hello')).toBe(true); - expect(handler2.callCount).toBe(1); + expect(handler1).toHaveBeenCalledWith('hello'); + expect(handler1.mock.calls).toHaveLength(1); + expect(handler2).toHaveBeenCalledWith('hello'); + expect(handler2.mock.calls).toHaveLength(1); }); describe('on first state change with an initial payload function registered', () => { @@ -479,7 +474,7 @@ describe('Messenger', () => { eventType: 'Fixture:complexMessage', getPayload: () => [state], }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -489,8 +484,8 @@ describe('Messenger', () => { state.propA += 1; messenger.publish('Fixture:complexMessage', state); - expect(handler.getCall(0)?.args).toStrictEqual([2, 1]); - expect(handler.callCount).toBe(1); + expect(handler.mock.calls[0]).toStrictEqual([2, 1]); + expect(handler.mock.calls).toHaveLength(1); }); it('does not publish event if selected payload is the same', () => { @@ -509,7 +504,7 @@ describe('Messenger', () => { eventType: 'Fixture:complexMessage', getPayload: () => [state], }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -518,7 +513,7 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', state); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); }); }); @@ -539,7 +534,7 @@ describe('Messenger', () => { eventType: 'Fixture:complexMessage', getPayload: () => [state], }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -549,8 +544,8 @@ describe('Messenger', () => { state.propA += 1; messenger.publish('Fixture:complexMessage', state); - expect(handler.getCall(0)?.args).toStrictEqual([2, 1]); - expect(handler.callCount).toBe(1); + expect(handler.mock.calls[0]).toStrictEqual([2, 1]); + expect(handler.mock.calls).toHaveLength(1); }); it('does not publish event if selected payload is the same', () => { @@ -569,7 +564,7 @@ describe('Messenger', () => { eventType: 'Fixture:complexMessage', getPayload: () => [state], }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -578,7 +573,7 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', state); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); }); }); @@ -595,7 +590,7 @@ describe('Messenger', () => { const messenger = new Messenger<'Fixture', never, MessageEvent>({ namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -605,8 +600,8 @@ describe('Messenger', () => { state.propA += 1; messenger.publish('Fixture:complexMessage', state); - expect(handler.getCall(0)?.args).toStrictEqual([2, undefined]); - expect(handler.callCount).toBe(1); + expect(handler.mock.calls[0]).toStrictEqual([2, undefined]); + expect(handler.mock.calls).toHaveLength(1); }); it('publishes event even when selected payload does not change', () => { @@ -621,7 +616,7 @@ describe('Messenger', () => { const messenger = new Messenger<'Fixture', never, MessageEvent>({ namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -630,8 +625,8 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', state); - expect(handler.getCall(0)?.args).toStrictEqual([1, undefined]); - expect(handler.callCount).toBe(1); + expect(handler.mock.calls[0]).toStrictEqual([1, undefined]); + expect(handler.mock.calls).toHaveLength(1); }); it('does not publish if selector returns undefined', () => { @@ -646,7 +641,7 @@ describe('Messenger', () => { const messenger = new Messenger<'Fixture', never, MessageEvent>({ namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -655,7 +650,7 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', state); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); }); }); @@ -669,7 +664,7 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -678,9 +673,9 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); messenger.publish('Fixture:complexMessage', { prop1: 'z', prop2: 'b' }); - expect(handler.getCall(0).calledWithExactly('a', undefined)).toBe(true); - expect(handler.getCall(1).calledWithExactly('z', 'a')).toBe(true); - expect(handler.callCount).toBe(2); + expect(handler.mock.calls[0]).toStrictEqual(['a', undefined]); + expect(handler.mock.calls[1]).toStrictEqual(['z', 'a']); + expect(handler.mock.calls).toHaveLength(2); }); it('publishes event with selector to subscriber', () => { @@ -692,7 +687,7 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -700,8 +695,8 @@ describe('Messenger', () => { ); messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('a', undefined); + expect(handler.mock.calls).toHaveLength(1); }); it('does not publish event with selector if selector return value is unchanged', () => { @@ -713,7 +708,7 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe( 'Fixture:complexMessage', handler, @@ -722,8 +717,8 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); messenger.publish('Fixture:complexMessage', { prop1: 'a', prop3: 'c' }); - expect(handler.calledWithExactly('a', undefined)).toBe(true); - expect(handler.callCount).toBe(1); + expect(handler).toHaveBeenCalledWith('a', undefined); + expect(handler.mock.calls).toHaveLength(1); }); }); @@ -748,7 +743,7 @@ describe('Messenger', () => { namespace: 'Fixture', parent: parentMessenger, }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.registerInitialEventPayload({ eventType: 'Fixture:complexMessage', @@ -761,11 +756,11 @@ describe('Messenger', () => { (obj) => obj.propA, ); messenger.publish('Fixture:complexMessage', state); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); state.propA += 1; messenger.publish('Fixture:complexMessage', state); - expect(handler.getCall(0)?.args).toStrictEqual([2, 1]); - expect(handler.callCount).toBe(1); + expect(handler.mock.calls[0]).toStrictEqual([2, 1]); + expect(handler.mock.calls).toHaveLength(1); }); it('publishes event to many subscribers with the same selector', () => { @@ -777,34 +772,31 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); - const selector = sinon.fake((obj: Record) => obj.prop1); + const handler1 = jest.fn(); + const handler2 = jest.fn(); + const selector = jest.fn((obj: Record) => obj.prop1); messenger.subscribe('Fixture:complexMessage', handler1, selector); messenger.subscribe('Fixture:complexMessage', handler2, selector); messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); messenger.publish('Fixture:complexMessage', { prop1: 'a', prop3: 'c' }); - expect(handler1.calledWithExactly('a', undefined)).toBe(true); - expect(handler1.callCount).toBe(1); - expect(handler2.calledWithExactly('a', undefined)).toBe(true); - expect(handler2.callCount).toBe(1); - expect( - selector.getCall(0).calledWithExactly({ prop1: 'a', prop2: 'b' }), - ).toBe(true); - - expect( - selector.getCall(1).calledWithExactly({ prop1: 'a', prop2: 'b' }), - ).toBe(true); - - expect( - selector.getCall(2).calledWithExactly({ prop1: 'a', prop3: 'c' }), - ).toBe(true); - - expect( - selector.getCall(3).calledWithExactly({ prop1: 'a', prop3: 'c' }), - ).toBe(true); - expect(selector.callCount).toBe(4); + expect(handler1).toHaveBeenCalledWith('a', undefined); + expect(handler1.mock.calls).toHaveLength(1); + expect(handler2).toHaveBeenCalledWith('a', undefined); + expect(handler2.mock.calls).toHaveLength(1); + expect(selector.mock.calls[0]).toStrictEqual([ + { prop1: 'a', prop2: 'b' }, + ]); + expect(selector.mock.calls[1]).toStrictEqual([ + { prop1: 'a', prop2: 'b' }, + ]); + expect(selector.mock.calls[2]).toStrictEqual([ + { prop1: 'a', prop3: 'c' }, + ]); + expect(selector.mock.calls[3]).toStrictEqual([ + { prop1: 'a', prop3: 'c' }, + ]); + expect(selector.mock.calls).toHaveLength(4); }); it('captures subscriber errors using captureException', () => { @@ -816,7 +808,9 @@ describe('Messenger', () => { }); const exampleError = new Error('Example error'); - const handler = sinon.stub().throws(() => exampleError); + const handler = jest.fn(() => { + throw exampleError; + }); messenger.subscribe('Fixture:message', handler); expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); @@ -833,7 +827,11 @@ describe('Messenger', () => { }); const exampleException = 'Non-error thrown value'; - const handler = sinon.stub().throws(() => exampleException); + const handler = jest.fn(() => { + // Intentionally throw a non-Error to test that Messenger wraps it + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw exampleException; + }); messenger.subscribe('Fixture:message', handler); expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); @@ -861,7 +859,9 @@ describe('Messenger', () => { }); const exampleError = new Error('Example error'); - const handler = sinon.stub().throws(() => exampleError); + const handler = jest.fn(() => { + throw exampleError; + }); messenger.subscribe('Fixture:message', handler); expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); @@ -878,7 +878,9 @@ describe('Messenger', () => { }); const exampleError = new Error('Example error'); - const handler = sinon.stub().throws(() => exampleError); + const handler = jest.fn(() => { + throw exampleError; + }); messenger.subscribe('Fixture:message', handler); expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); @@ -893,17 +895,19 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler1 = sinon.stub().throws(() => new Error('Example error')); - const handler2 = sinon.stub(); + const handler1 = jest.fn(() => { + throw new Error('Example error'); + }); + const handler2 = jest.fn(); messenger.subscribe('Fixture:message', handler1); messenger.subscribe('Fixture:message', handler2); expect(() => messenger.publish('Fixture:message', 'hello')).not.toThrow(); - expect(handler1.calledWithExactly('hello')).toBe(true); - expect(handler1.callCount).toBe(1); - expect(handler2.calledWithExactly('hello')).toBe(true); - expect(handler2.callCount).toBe(1); + expect(handler1).toHaveBeenCalledWith('hello'); + expect(handler1.mock.calls).toHaveLength(1); + expect(handler2).toHaveBeenCalledWith('hello'); + expect(handler2.mock.calls).toHaveLength(1); }); it('does not call subscriber after unsubscribing', () => { @@ -912,12 +916,12 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.unsubscribe('Fixture:message', handler); messenger.publish('Fixture:message', 'hello'); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); }); it('does not call subscriber with selector after unsubscribing', () => { @@ -928,7 +932,7 @@ describe('Messenger', () => { const messenger = new Messenger<'Fixture', never, MessageEvent>({ namespace: 'Fixture', }); - const stub = sinon.stub(); + const stub = jest.fn(); const handler = (current: string, previous: string | undefined): void => { stub(current, previous); }; @@ -939,7 +943,7 @@ describe('Messenger', () => { messenger.publish('Fixture:complexMessage', { prop1: 'a', prop2: 'b' }); - expect(stub.callCount).toBe(0); + expect(stub.mock.calls).toHaveLength(0); }); it('throws when publishing an event from another namespace', () => { @@ -984,7 +988,7 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); expect(() => messenger.unsubscribe('Fixture:message', handler)).toThrow( 'Subscription not found for event: Fixture:message', ); @@ -996,8 +1000,8 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler1 = sinon.stub(); - const handler2 = sinon.stub(); + const handler1 = jest.fn(); + const handler2 = jest.fn(); messenger.subscribe('Fixture:message', handler1); expect(() => messenger.unsubscribe('Fixture:message', handler2)).toThrow( @@ -1013,12 +1017,12 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.clearEventSubscriptions('Fixture:message'); messenger.publish('Fixture:message', 'hello'); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); }); it('does not throw when clearing event that has no subscriptions', () => { @@ -1066,12 +1070,12 @@ describe('Messenger', () => { namespace: 'Fixture', }); - const handler = sinon.stub(); + const handler = jest.fn(); messenger.subscribe('Fixture:message', handler); messenger.clearSubscriptions(); messenger.publish('Fixture:message', 'hello'); - expect(handler.callCount).toBe(0); + expect(handler.mock.calls).toHaveLength(0); }); it('does not throw when clearing subscriptions on messenger that has no subscriptions', () => { diff --git a/packages/messenger/src/index.test.ts b/packages/messenger/src/index.test.ts index 7d9463055f3..159ada030d1 100644 --- a/packages/messenger/src/index.test.ts +++ b/packages/messenger/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/messenger', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "MOCK_ANY_NAMESPACE", "Messenger", ] diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index 2be5d3ff564..c611c19d90b 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.0.0] + ### Changed - **BREAKING:** Bump peer dependency `@metamask/account-api` from `^0.12.0` to `^1.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) @@ -15,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Use new `AccountProvider.createAccounts` method with `CreateAccountOptions` ([#7857](https://github.com/MetaMask/core/pull/7857)) - All account providers now accept `CreateAccountOptions` with `type` field. - Added `capabilities` property to all account providers defining supported account creation types. +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-internal-api` from `^9.0.0` to `^10.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-snap-client` from `^8.0.0` to `^8.2.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) @@ -376,7 +379,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MultichainAccountService` ([#6141](https://github.com/MetaMask/core/pull/6141)), ([#6165](https://github.com/MetaMask/core/pull/6165)) - This service manages multichain accounts/wallets. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@6.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@7.0.0...HEAD +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@6.0.0...@metamask/multichain-account-service@7.0.0 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@5.1.0...@metamask/multichain-account-service@6.0.0 [5.1.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@5.0.0...@metamask/multichain-account-service@5.1.0 [5.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@4.1.0...@metamask/multichain-account-service@5.0.0 diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index 5852e677605..1c273b54ce9 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-account-service", - "version": "6.0.0", + "version": "7.0.0", "description": "Service to manage multichain accounts", "keywords": [ "MetaMask", @@ -49,7 +49,7 @@ }, "dependencies": { "@ethereumjs/util": "^9.1.0", - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/eth-snap-keyring": "^19.0.0", "@metamask/key-tree": "^10.1.1", @@ -73,12 +73,12 @@ "@metamask/eth-hd-keyring": "^13.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/uuid": "^8.3.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "uuid": "^8.3.2", diff --git a/packages/multichain-api-middleware/CHANGELOG.md b/packages/multichain-api-middleware/CHANGELOG.md index 5f2e0825c70..b822d2a0ef0 100644 --- a/packages/multichain-api-middleware/CHANGELOG.md +++ b/packages/multichain-api-middleware/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/json-rpc-engine` from `^10.2.1` to `^10.2.2` ([#7856](https://github.com/MetaMask/core/pull/7856)) +- Bump `@metamask/multichain-transactions-controller` from `7.0.0` to `7.0.1` ([#7897](https://github.com/MetaMask/core/pull/7897)) ## [1.2.6] diff --git a/packages/multichain-api-middleware/package.json b/packages/multichain-api-middleware/package.json index ae6f1e53fec..3599856bb5c 100644 --- a/packages/multichain-api-middleware/package.json +++ b/packages/multichain-api-middleware/package.json @@ -63,14 +63,14 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-filters": "^9.0.0", - "@metamask/multichain-transactions-controller": "^7.0.0", + "@metamask/multichain-transactions-controller": "^7.0.1", "@metamask/safe-event-emitter": "^3.0.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/multichain-api-middleware/src/index.test.ts b/packages/multichain-api-middleware/src/index.test.ts index 1ca14692284..5792d47e342 100644 --- a/packages/multichain-api-middleware/src/index.test.ts +++ b/packages/multichain-api-middleware/src/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '.'; describe('@metamask/multichain-api-middleware', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "walletCreateSession", "walletGetSession", "walletInvokeMethod", diff --git a/packages/multichain-network-controller/CHANGELOG.md b/packages/multichain-network-controller/CHANGELOG.md index 7f2a528717f..9572429db18 100644 --- a/packages/multichain-network-controller/CHANGELOG.md +++ b/packages/multichain-network-controller/CHANGELOG.md @@ -7,8 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.3] + ### Changed +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-internal-api` from `^9.0.0` to `^10.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) @@ -222,7 +225,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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@3.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.3...HEAD +[3.0.3]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.2...@metamask/multichain-network-controller@3.0.3 [3.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.1...@metamask/multichain-network-controller@3.0.2 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@3.0.0...@metamask/multichain-network-controller@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-network-controller@2.0.0...@metamask/multichain-network-controller@3.0.0 diff --git a/packages/multichain-network-controller/package.json b/packages/multichain-network-controller/package.json index cb92a5ea4e2..57b35e9f580 100644 --- a/packages/multichain-network-controller/package.json +++ b/packages/multichain-network-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-network-controller", - "version": "3.0.2", + "version": "3.0.3", "description": "Multichain network controller", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/keyring-api": "^21.5.0", @@ -64,15 +64,15 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^25.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/lodash": "^4.14.191", "@types/uuid": "^8.3.0", "deepmerge": "^4.2.2", "immer": "^9.0.6", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts index 9cf4ab8ec05..41b0467dd7d 100644 --- a/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts +++ b/packages/multichain-network-controller/src/MultichainNetworkController/MultichainNetworkController.test.ts @@ -727,77 +727,77 @@ describe('MultichainNetworkController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "isEvmSelected": true, - "multichainNetworkConfigurationsByChainId": Object { - "bip122:000000000019d6689c085ae165831e93": Object { + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { "chainId": "bip122:000000000019d6689c085ae165831e93", "isEvm": false, "name": "Bitcoin", "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", }, - "bip122:000000000933ea01ad0ee984209779ba": Object { + "bip122:000000000933ea01ad0ee984209779ba": { "chainId": "bip122:000000000933ea01ad0ee984209779ba", "isEvm": false, "name": "Bitcoin Testnet", "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", }, - "bip122:00000000da84f2bafbbc53dee25a72ae": Object { + "bip122:00000000da84f2bafbbc53dee25a72ae": { "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", "isEvm": false, "name": "Bitcoin Testnet4", "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", }, - "bip122:00000008819873e925422c1ff0f99f7c": Object { + "bip122:00000008819873e925422c1ff0f99f7c": { "chainId": "bip122:00000008819873e925422c1ff0f99f7c", "isEvm": false, "name": "Bitcoin Mutinynet", "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", }, - "bip122:regtest": Object { + "bip122:regtest": { "chainId": "bip122:regtest", "isEvm": false, "name": "Bitcoin Regtest", "nativeCurrency": "bip122:regtest/slip44:0", }, - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", "isEvm": false, "name": "Solana Testnet", "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", }, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": Object { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "isEvm": false, "name": "Solana", "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", }, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": Object { + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "isEvm": false, "name": "Solana Devnet", "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", }, - "tron:2494104990": Object { + "tron:2494104990": { "chainId": "tron:2494104990", "isEvm": false, "name": "Tron Shasta", "nativeCurrency": "tron:2494104990/slip44:195", }, - "tron:3448148188": Object { + "tron:3448148188": { "chainId": "tron:3448148188", "isEvm": false, "name": "Tron Nile", "nativeCurrency": "tron:3448148188/slip44:195", }, - "tron:728126428": Object { + "tron:728126428": { "chainId": "tron:728126428", "isEvm": false, "name": "Tron", "nativeCurrency": "tron:728126428/slip44:195", }, }, - "networksWithTransactionActivity": Object {}, + "networksWithTransactionActivity": {}, "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", } `); @@ -813,77 +813,77 @@ describe('MultichainNetworkController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "isEvmSelected": true, - "multichainNetworkConfigurationsByChainId": Object { - "bip122:000000000019d6689c085ae165831e93": Object { + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { "chainId": "bip122:000000000019d6689c085ae165831e93", "isEvm": false, "name": "Bitcoin", "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", }, - "bip122:000000000933ea01ad0ee984209779ba": Object { + "bip122:000000000933ea01ad0ee984209779ba": { "chainId": "bip122:000000000933ea01ad0ee984209779ba", "isEvm": false, "name": "Bitcoin Testnet", "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", }, - "bip122:00000000da84f2bafbbc53dee25a72ae": Object { + "bip122:00000000da84f2bafbbc53dee25a72ae": { "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", "isEvm": false, "name": "Bitcoin Testnet4", "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", }, - "bip122:00000008819873e925422c1ff0f99f7c": Object { + "bip122:00000008819873e925422c1ff0f99f7c": { "chainId": "bip122:00000008819873e925422c1ff0f99f7c", "isEvm": false, "name": "Bitcoin Mutinynet", "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", }, - "bip122:regtest": Object { + "bip122:regtest": { "chainId": "bip122:regtest", "isEvm": false, "name": "Bitcoin Regtest", "nativeCurrency": "bip122:regtest/slip44:0", }, - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", "isEvm": false, "name": "Solana Testnet", "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", }, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": Object { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "isEvm": false, "name": "Solana", "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", }, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": Object { + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "isEvm": false, "name": "Solana Devnet", "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", }, - "tron:2494104990": Object { + "tron:2494104990": { "chainId": "tron:2494104990", "isEvm": false, "name": "Tron Shasta", "nativeCurrency": "tron:2494104990/slip44:195", }, - "tron:3448148188": Object { + "tron:3448148188": { "chainId": "tron:3448148188", "isEvm": false, "name": "Tron Nile", "nativeCurrency": "tron:3448148188/slip44:195", }, - "tron:728126428": Object { + "tron:728126428": { "chainId": "tron:728126428", "isEvm": false, "name": "Tron", "nativeCurrency": "tron:728126428/slip44:195", }, }, - "networksWithTransactionActivity": Object {}, + "networksWithTransactionActivity": {}, "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", } `); @@ -899,77 +899,77 @@ describe('MultichainNetworkController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "isEvmSelected": true, - "multichainNetworkConfigurationsByChainId": Object { - "bip122:000000000019d6689c085ae165831e93": Object { + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { "chainId": "bip122:000000000019d6689c085ae165831e93", "isEvm": false, "name": "Bitcoin", "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", }, - "bip122:000000000933ea01ad0ee984209779ba": Object { + "bip122:000000000933ea01ad0ee984209779ba": { "chainId": "bip122:000000000933ea01ad0ee984209779ba", "isEvm": false, "name": "Bitcoin Testnet", "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", }, - "bip122:00000000da84f2bafbbc53dee25a72ae": Object { + "bip122:00000000da84f2bafbbc53dee25a72ae": { "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", "isEvm": false, "name": "Bitcoin Testnet4", "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", }, - "bip122:00000008819873e925422c1ff0f99f7c": Object { + "bip122:00000008819873e925422c1ff0f99f7c": { "chainId": "bip122:00000008819873e925422c1ff0f99f7c", "isEvm": false, "name": "Bitcoin Mutinynet", "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", }, - "bip122:regtest": Object { + "bip122:regtest": { "chainId": "bip122:regtest", "isEvm": false, "name": "Bitcoin Regtest", "nativeCurrency": "bip122:regtest/slip44:0", }, - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", "isEvm": false, "name": "Solana Testnet", "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", }, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": Object { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "isEvm": false, "name": "Solana", "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", }, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": Object { + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "isEvm": false, "name": "Solana Devnet", "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", }, - "tron:2494104990": Object { + "tron:2494104990": { "chainId": "tron:2494104990", "isEvm": false, "name": "Tron Shasta", "nativeCurrency": "tron:2494104990/slip44:195", }, - "tron:3448148188": Object { + "tron:3448148188": { "chainId": "tron:3448148188", "isEvm": false, "name": "Tron Nile", "nativeCurrency": "tron:3448148188/slip44:195", }, - "tron:728126428": Object { + "tron:728126428": { "chainId": "tron:728126428", "isEvm": false, "name": "Tron", "nativeCurrency": "tron:728126428/slip44:195", }, }, - "networksWithTransactionActivity": Object {}, + "networksWithTransactionActivity": {}, "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", } `); @@ -985,77 +985,77 @@ describe('MultichainNetworkController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "isEvmSelected": true, - "multichainNetworkConfigurationsByChainId": Object { - "bip122:000000000019d6689c085ae165831e93": Object { + "multichainNetworkConfigurationsByChainId": { + "bip122:000000000019d6689c085ae165831e93": { "chainId": "bip122:000000000019d6689c085ae165831e93", "isEvm": false, "name": "Bitcoin", "nativeCurrency": "bip122:000000000019d6689c085ae165831e93/slip44:0", }, - "bip122:000000000933ea01ad0ee984209779ba": Object { + "bip122:000000000933ea01ad0ee984209779ba": { "chainId": "bip122:000000000933ea01ad0ee984209779ba", "isEvm": false, "name": "Bitcoin Testnet", "nativeCurrency": "bip122:000000000933ea01ad0ee984209779ba/slip44:0", }, - "bip122:00000000da84f2bafbbc53dee25a72ae": Object { + "bip122:00000000da84f2bafbbc53dee25a72ae": { "chainId": "bip122:00000000da84f2bafbbc53dee25a72ae", "isEvm": false, "name": "Bitcoin Testnet4", "nativeCurrency": "bip122:00000000da84f2bafbbc53dee25a72ae/slip44:0", }, - "bip122:00000008819873e925422c1ff0f99f7c": Object { + "bip122:00000008819873e925422c1ff0f99f7c": { "chainId": "bip122:00000008819873e925422c1ff0f99f7c", "isEvm": false, "name": "Bitcoin Mutinynet", "nativeCurrency": "bip122:00000008819873e925422c1ff0f99f7c/slip44:0", }, - "bip122:regtest": Object { + "bip122:regtest": { "chainId": "bip122:regtest", "isEvm": false, "name": "Bitcoin Regtest", "nativeCurrency": "bip122:regtest/slip44:0", }, - "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": Object { + "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z": { "chainId": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z", "isEvm": false, "name": "Solana Testnet", "nativeCurrency": "solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z/slip44:501", }, - "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": Object { + "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": { "chainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "isEvm": false, "name": "Solana", "nativeCurrency": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", }, - "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": Object { + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": { "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", "isEvm": false, "name": "Solana Devnet", "nativeCurrency": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1/slip44:501", }, - "tron:2494104990": Object { + "tron:2494104990": { "chainId": "tron:2494104990", "isEvm": false, "name": "Tron Shasta", "nativeCurrency": "tron:2494104990/slip44:195", }, - "tron:3448148188": Object { + "tron:3448148188": { "chainId": "tron:3448148188", "isEvm": false, "name": "Tron Nile", "nativeCurrency": "tron:3448148188/slip44:195", }, - "tron:728126428": Object { + "tron:728126428": { "chainId": "tron:728126428", "isEvm": false, "name": "Tron", "nativeCurrency": "tron:728126428/slip44:195", }, }, - "networksWithTransactionActivity": Object {}, + "networksWithTransactionActivity": {}, "selectedMultichainNetworkChainId": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", } `); diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index fb6fca03601..51951d3664b 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -7,18 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.0.1] + ### Changed - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-internal-api` from `^9.0.0` to `^10.0.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) - Bump `@metamask/keyring-snap-client` from `^8.0.0` to `^8.2.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) -- Bump `@metamask/accounts-controller` from `^35.0.1` to `^35.0.2` ([#7642](https://github.com/MetaMask/core/pull/7642)) - Bump `@metamask/snaps-sdk` from `^9.0.0` to `^10.3.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) - Bump `@metamask/snaps-utils` from `^11.0.0` to `^11.7.0` ([#7550](https://github.com/MetaMask/core/pull/7550)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) -- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7550](https://github.com/MetaMask/core/pull/7550), [#7604](https://github.com/MetaMask/core/pull/7604)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7550](https://github.com/MetaMask/core/pull/7550), [#7604](https://github.com/MetaMask/core/pull/7604)), ([#7642](https://github.com/MetaMask/core/pull/7642), [#7897](https://github.com/MetaMask/core/pull/7897)) - The dependencies moved are: - - `@metamask/accounts-controller` (^35.0.1) + - `@metamask/accounts-controller` (^36.0.0) - `@metamask/snaps-controllers` (^17.2.0) - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. @@ -238,7 +239,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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@7.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.0.1...HEAD +[7.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@7.0.0...@metamask/multichain-transactions-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@6.0.0...@metamask/multichain-transactions-controller@7.0.0 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@5.1.1...@metamask/multichain-transactions-controller@6.0.0 [5.1.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-transactions-controller@5.1.0...@metamask/multichain-transactions-controller@5.1.1 diff --git a/packages/multichain-transactions-controller/package.json b/packages/multichain-transactions-controller/package.json index be0bd0ff29a..a43dad086bc 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": "7.0.0", + "version": "7.0.1", "description": "This package is responsible for getting transactions from our Bitcoin and Solana snaps", "keywords": [ "MetaMask", @@ -48,7 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/keyring-api": "^21.5.0", "@metamask/keyring-internal-api": "^10.0.0", @@ -67,11 +67,11 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-controller": "^25.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts index 2fc19e46a87..cf1ac2319d2 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -1012,7 +1012,7 @@ describe('MultichainTransactionsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -1025,8 +1025,8 @@ describe('MultichainTransactionsController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "nonEvmTransactions": Object {}, + { + "nonEvmTransactions": {}, } `); }); @@ -1041,8 +1041,8 @@ describe('MultichainTransactionsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "nonEvmTransactions": Object {}, + { + "nonEvmTransactions": {}, } `); }); @@ -1057,8 +1057,8 @@ describe('MultichainTransactionsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "nonEvmTransactions": Object {}, + { + "nonEvmTransactions": {}, } `); }); diff --git a/packages/name-controller/package.json b/packages/name-controller/package.json index 9817283e745..17de1f005e5 100644 --- a/packages/name-controller/package.json +++ b/packages/name-controller/package.json @@ -58,11 +58,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/name-controller/src/NameController.test.ts b/packages/name-controller/src/NameController.test.ts index 6a96f225b21..0896434d901 100644 --- a/packages/name-controller/src/NameController.test.ts +++ b/packages/name-controller/src/NameController.test.ts @@ -2767,7 +2767,7 @@ describe('NameController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -2783,10 +2783,10 @@ describe('NameController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "nameSources": Object {}, - "names": Object { - "ethereumAddress": Object {}, + { + "nameSources": {}, + "names": { + "ethereumAddress": {}, }, } `); @@ -2805,10 +2805,10 @@ describe('NameController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "nameSources": Object {}, - "names": Object { - "ethereumAddress": Object {}, + { + "nameSources": {}, + "names": { + "ethereumAddress": {}, }, } `); @@ -2827,10 +2827,10 @@ describe('NameController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "nameSources": Object {}, - "names": Object { - "ethereumAddress": Object {}, + { + "nameSources": {}, + "names": { + "ethereumAddress": {}, }, } `); diff --git a/packages/network-controller/package.json b/packages/network-controller/package.json index a8db34e4edd..831d16bb319 100644 --- a/packages/network-controller/package.json +++ b/packages/network-controller/package.json @@ -74,21 +74,23 @@ "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", "@types/deep-freeze-strict": "^1.1.0", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/jest-when": "^2.7.3", "@types/lodash": "^4.14.191", "@types/node-fetch": "^2.6.12", + "@types/sinon": "^9.0.10", "cockatiel": "^3.1.2", "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "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.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/network-controller/tests/NetworkController.test.ts b/packages/network-controller/tests/NetworkController.test.ts index 311333834ee..b2579fef738 100644 --- a/packages/network-controller/tests/NetworkController.test.ts +++ b/packages/network-controller/tests/NetworkController.test.ts @@ -501,152 +501,152 @@ describe('NetworkController', () => { it('initializes the state with some defaults', async () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "networkConfigurationsByChainId": Object { - "0x1": Object { - "blockExplorerUrls": Array [], + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [], "chainId": "0x1", "defaultRpcEndpointIndex": 0, "name": "Ethereum Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "mainnet", "type": "infura", "url": "https://mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x2105": Object { - "blockExplorerUrls": Array [], + "0x2105": { + "blockExplorerUrls": [], "chainId": "0x2105", "defaultRpcEndpointIndex": 0, "name": "Base Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "base-mainnet", "type": "infura", "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x38": Object { - "blockExplorerUrls": Array [], + "0x38": { + "blockExplorerUrls": [], "chainId": "0x38", "defaultRpcEndpointIndex": 0, "name": "BSC Mainnet", "nativeCurrency": "BNB", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "bsc-mainnet", "type": "infura", "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x531": Object { - "blockExplorerUrls": Array [], + "0x531": { + "blockExplorerUrls": [], "chainId": "0x531", "defaultRpcEndpointIndex": 0, "name": "Sei Mainnet", "nativeCurrency": "SEI", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sei-mainnet", "type": "infura", "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x89": Object { - "blockExplorerUrls": Array [], + "0x89": { + "blockExplorerUrls": [], "chainId": "0x89", "defaultRpcEndpointIndex": 0, "name": "Polygon Mainnet", "nativeCurrency": "POL", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "polygon-mainnet", "type": "infura", "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa": Object { - "blockExplorerUrls": Array [], + "0xa": { + "blockExplorerUrls": [], "chainId": "0xa", "defaultRpcEndpointIndex": 0, "name": "Optimism Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "optimism-mainnet", "type": "infura", "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa4b1": Object { - "blockExplorerUrls": Array [], + "0xa4b1": { + "blockExplorerUrls": [], "chainId": "0xa4b1", "defaultRpcEndpointIndex": 0, "name": "Arbitrum One", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "arbitrum-mainnet", "type": "infura", "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xaa36a7": Object { - "blockExplorerUrls": Array [], + "0xaa36a7": { + "blockExplorerUrls": [], "chainId": "0xaa36a7", "defaultRpcEndpointIndex": 0, "name": "Sepolia", "nativeCurrency": "SepoliaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sepolia", "type": "infura", "url": "https://sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe705": Object { - "blockExplorerUrls": Array [], + "0xe705": { + "blockExplorerUrls": [], "chainId": "0xe705", "defaultRpcEndpointIndex": 0, "name": "Linea Sepolia", "nativeCurrency": "LineaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-sepolia", "type": "infura", "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe708": Object { - "blockExplorerUrls": Array [], + "0xe708": { + "blockExplorerUrls": [], "chainId": "0xe708", "defaultRpcEndpointIndex": 0, "name": "Linea", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-mainnet", "type": "infura", "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", @@ -654,7 +654,7 @@ describe('NetworkController', () => { ], }, }, - "networksMetadata": Object {}, + "networksMetadata": {}, "selectedNetworkClientId": "mainnet", } `); @@ -670,25 +670,25 @@ describe('NetworkController', () => { }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "networkConfigurationsByChainId": Object { - "0x1": Object { - "blockExplorerUrls": Array [], + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [], "chainId": "0x1", "defaultRpcEndpointIndex": 0, "name": "Ethereum Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "mainnet", "type": "infura", "url": "https://mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x18c6": Object { - "blockExplorerUrls": Array [ + "0x18c6": { + "blockExplorerUrls": [ "https://megaexplorer.xyz", ], "chainId": "0x18c6", @@ -696,144 +696,144 @@ describe('NetworkController', () => { "defaultRpcEndpointIndex": 0, "name": "Mega Testnet", "nativeCurrency": "MegaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "megaeth-testnet", "type": "custom", "url": "https://carrot.megaeth.com/rpc", }, ], }, - "0x2105": Object { - "blockExplorerUrls": Array [], + "0x2105": { + "blockExplorerUrls": [], "chainId": "0x2105", "defaultRpcEndpointIndex": 0, "name": "Base Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "base-mainnet", "type": "infura", "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x38": Object { - "blockExplorerUrls": Array [], + "0x38": { + "blockExplorerUrls": [], "chainId": "0x38", "defaultRpcEndpointIndex": 0, "name": "BSC Mainnet", "nativeCurrency": "BNB", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "bsc-mainnet", "type": "infura", "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x531": Object { - "blockExplorerUrls": Array [], + "0x531": { + "blockExplorerUrls": [], "chainId": "0x531", "defaultRpcEndpointIndex": 0, "name": "Sei Mainnet", "nativeCurrency": "SEI", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sei-mainnet", "type": "infura", "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x89": Object { - "blockExplorerUrls": Array [], + "0x89": { + "blockExplorerUrls": [], "chainId": "0x89", "defaultRpcEndpointIndex": 0, "name": "Polygon Mainnet", "nativeCurrency": "POL", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "polygon-mainnet", "type": "infura", "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa": Object { - "blockExplorerUrls": Array [], + "0xa": { + "blockExplorerUrls": [], "chainId": "0xa", "defaultRpcEndpointIndex": 0, "name": "Optimism Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "optimism-mainnet", "type": "infura", "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa4b1": Object { - "blockExplorerUrls": Array [], + "0xa4b1": { + "blockExplorerUrls": [], "chainId": "0xa4b1", "defaultRpcEndpointIndex": 0, "name": "Arbitrum One", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "arbitrum-mainnet", "type": "infura", "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xaa36a7": Object { - "blockExplorerUrls": Array [], + "0xaa36a7": { + "blockExplorerUrls": [], "chainId": "0xaa36a7", "defaultRpcEndpointIndex": 0, "name": "Sepolia", "nativeCurrency": "SepoliaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sepolia", "type": "infura", "url": "https://sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe705": Object { - "blockExplorerUrls": Array [], + "0xe705": { + "blockExplorerUrls": [], "chainId": "0xe705", "defaultRpcEndpointIndex": 0, "name": "Linea Sepolia", "nativeCurrency": "LineaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-sepolia", "type": "infura", "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe708": Object { - "blockExplorerUrls": Array [], + "0xe708": { + "blockExplorerUrls": [], "chainId": "0xe708", "defaultRpcEndpointIndex": 0, "name": "Linea", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-mainnet", "type": "infura", "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", @@ -841,7 +841,7 @@ describe('NetworkController', () => { ], }, }, - "networksMetadata": Object {}, + "networksMetadata": {}, "selectedNetworkClientId": "mainnet", } `); @@ -883,10 +883,10 @@ describe('NetworkController', () => { }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "networkConfigurationsByChainId": Object { - "0xaa36a7": Object { - "blockExplorerUrls": Array [ + { + "networkConfigurationsByChainId": { + "0xaa36a7": { + "blockExplorerUrls": [ "https://block.explorer", ], "chainId": "0xaa36a7", @@ -894,9 +894,9 @@ describe('NetworkController', () => { "defaultRpcEndpointIndex": 0, "name": "Sepolia", "nativeCurrency": "SepoliaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [ + "rpcEndpoints": [ + { + "failoverUrls": [ "https://failover.endpoint", ], "name": "Sepolia", @@ -907,9 +907,9 @@ describe('NetworkController', () => { ], }, }, - "networksMetadata": Object { - "sepolia": Object { - "EIPS": Object { + "networksMetadata": { + "sepolia": { + "EIPS": { "1559": true, }, "status": "unknown", @@ -14880,7 +14880,7 @@ describe('NetworkController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -14893,152 +14893,152 @@ describe('NetworkController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "networkConfigurationsByChainId": Object { - "0x1": Object { - "blockExplorerUrls": Array [], + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [], "chainId": "0x1", "defaultRpcEndpointIndex": 0, "name": "Ethereum Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "mainnet", "type": "infura", "url": "https://mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x2105": Object { - "blockExplorerUrls": Array [], + "0x2105": { + "blockExplorerUrls": [], "chainId": "0x2105", "defaultRpcEndpointIndex": 0, "name": "Base Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "base-mainnet", "type": "infura", "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x38": Object { - "blockExplorerUrls": Array [], + "0x38": { + "blockExplorerUrls": [], "chainId": "0x38", "defaultRpcEndpointIndex": 0, "name": "BSC Mainnet", "nativeCurrency": "BNB", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "bsc-mainnet", "type": "infura", "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x531": Object { - "blockExplorerUrls": Array [], + "0x531": { + "blockExplorerUrls": [], "chainId": "0x531", "defaultRpcEndpointIndex": 0, "name": "Sei Mainnet", "nativeCurrency": "SEI", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sei-mainnet", "type": "infura", "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x89": Object { - "blockExplorerUrls": Array [], + "0x89": { + "blockExplorerUrls": [], "chainId": "0x89", "defaultRpcEndpointIndex": 0, "name": "Polygon Mainnet", "nativeCurrency": "POL", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "polygon-mainnet", "type": "infura", "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa": Object { - "blockExplorerUrls": Array [], + "0xa": { + "blockExplorerUrls": [], "chainId": "0xa", "defaultRpcEndpointIndex": 0, "name": "Optimism Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "optimism-mainnet", "type": "infura", "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa4b1": Object { - "blockExplorerUrls": Array [], + "0xa4b1": { + "blockExplorerUrls": [], "chainId": "0xa4b1", "defaultRpcEndpointIndex": 0, "name": "Arbitrum One", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "arbitrum-mainnet", "type": "infura", "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xaa36a7": Object { - "blockExplorerUrls": Array [], + "0xaa36a7": { + "blockExplorerUrls": [], "chainId": "0xaa36a7", "defaultRpcEndpointIndex": 0, "name": "Sepolia", "nativeCurrency": "SepoliaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sepolia", "type": "infura", "url": "https://sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe705": Object { - "blockExplorerUrls": Array [], + "0xe705": { + "blockExplorerUrls": [], "chainId": "0xe705", "defaultRpcEndpointIndex": 0, "name": "Linea Sepolia", "nativeCurrency": "LineaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-sepolia", "type": "infura", "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe708": Object { - "blockExplorerUrls": Array [], + "0xe708": { + "blockExplorerUrls": [], "chainId": "0xe708", "defaultRpcEndpointIndex": 0, "name": "Linea", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-mainnet", "type": "infura", "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", @@ -15046,7 +15046,7 @@ describe('NetworkController', () => { ], }, }, - "networksMetadata": Object {}, + "networksMetadata": {}, "selectedNetworkClientId": "mainnet", } `); @@ -15062,152 +15062,152 @@ describe('NetworkController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "networkConfigurationsByChainId": Object { - "0x1": Object { - "blockExplorerUrls": Array [], + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [], "chainId": "0x1", "defaultRpcEndpointIndex": 0, "name": "Ethereum Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "mainnet", "type": "infura", "url": "https://mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x2105": Object { - "blockExplorerUrls": Array [], + "0x2105": { + "blockExplorerUrls": [], "chainId": "0x2105", "defaultRpcEndpointIndex": 0, "name": "Base Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "base-mainnet", "type": "infura", "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x38": Object { - "blockExplorerUrls": Array [], + "0x38": { + "blockExplorerUrls": [], "chainId": "0x38", "defaultRpcEndpointIndex": 0, "name": "BSC Mainnet", "nativeCurrency": "BNB", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "bsc-mainnet", "type": "infura", "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x531": Object { - "blockExplorerUrls": Array [], + "0x531": { + "blockExplorerUrls": [], "chainId": "0x531", "defaultRpcEndpointIndex": 0, "name": "Sei Mainnet", "nativeCurrency": "SEI", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sei-mainnet", "type": "infura", "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x89": Object { - "blockExplorerUrls": Array [], + "0x89": { + "blockExplorerUrls": [], "chainId": "0x89", "defaultRpcEndpointIndex": 0, "name": "Polygon Mainnet", "nativeCurrency": "POL", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "polygon-mainnet", "type": "infura", "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa": Object { - "blockExplorerUrls": Array [], + "0xa": { + "blockExplorerUrls": [], "chainId": "0xa", "defaultRpcEndpointIndex": 0, "name": "Optimism Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "optimism-mainnet", "type": "infura", "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa4b1": Object { - "blockExplorerUrls": Array [], + "0xa4b1": { + "blockExplorerUrls": [], "chainId": "0xa4b1", "defaultRpcEndpointIndex": 0, "name": "Arbitrum One", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "arbitrum-mainnet", "type": "infura", "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xaa36a7": Object { - "blockExplorerUrls": Array [], + "0xaa36a7": { + "blockExplorerUrls": [], "chainId": "0xaa36a7", "defaultRpcEndpointIndex": 0, "name": "Sepolia", "nativeCurrency": "SepoliaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sepolia", "type": "infura", "url": "https://sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe705": Object { - "blockExplorerUrls": Array [], + "0xe705": { + "blockExplorerUrls": [], "chainId": "0xe705", "defaultRpcEndpointIndex": 0, "name": "Linea Sepolia", "nativeCurrency": "LineaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-sepolia", "type": "infura", "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe708": Object { - "blockExplorerUrls": Array [], + "0xe708": { + "blockExplorerUrls": [], "chainId": "0xe708", "defaultRpcEndpointIndex": 0, "name": "Linea", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-mainnet", "type": "infura", "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", @@ -15215,7 +15215,7 @@ describe('NetworkController', () => { ], }, }, - "networksMetadata": Object {}, + "networksMetadata": {}, "selectedNetworkClientId": "mainnet", } `); @@ -15231,152 +15231,152 @@ describe('NetworkController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "networkConfigurationsByChainId": Object { - "0x1": Object { - "blockExplorerUrls": Array [], + { + "networkConfigurationsByChainId": { + "0x1": { + "blockExplorerUrls": [], "chainId": "0x1", "defaultRpcEndpointIndex": 0, "name": "Ethereum Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "mainnet", "type": "infura", "url": "https://mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x2105": Object { - "blockExplorerUrls": Array [], + "0x2105": { + "blockExplorerUrls": [], "chainId": "0x2105", "defaultRpcEndpointIndex": 0, "name": "Base Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "base-mainnet", "type": "infura", "url": "https://base-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x38": Object { - "blockExplorerUrls": Array [], + "0x38": { + "blockExplorerUrls": [], "chainId": "0x38", "defaultRpcEndpointIndex": 0, "name": "BSC Mainnet", "nativeCurrency": "BNB", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "bsc-mainnet", "type": "infura", "url": "https://bsc-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x531": Object { - "blockExplorerUrls": Array [], + "0x531": { + "blockExplorerUrls": [], "chainId": "0x531", "defaultRpcEndpointIndex": 0, "name": "Sei Mainnet", "nativeCurrency": "SEI", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sei-mainnet", "type": "infura", "url": "https://sei-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0x89": Object { - "blockExplorerUrls": Array [], + "0x89": { + "blockExplorerUrls": [], "chainId": "0x89", "defaultRpcEndpointIndex": 0, "name": "Polygon Mainnet", "nativeCurrency": "POL", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "polygon-mainnet", "type": "infura", "url": "https://polygon-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa": Object { - "blockExplorerUrls": Array [], + "0xa": { + "blockExplorerUrls": [], "chainId": "0xa", "defaultRpcEndpointIndex": 0, "name": "Optimism Mainnet", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "optimism-mainnet", "type": "infura", "url": "https://optimism-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xa4b1": Object { - "blockExplorerUrls": Array [], + "0xa4b1": { + "blockExplorerUrls": [], "chainId": "0xa4b1", "defaultRpcEndpointIndex": 0, "name": "Arbitrum One", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "arbitrum-mainnet", "type": "infura", "url": "https://arbitrum-mainnet.infura.io/v3/{infuraProjectId}", }, ], }, - "0xaa36a7": Object { - "blockExplorerUrls": Array [], + "0xaa36a7": { + "blockExplorerUrls": [], "chainId": "0xaa36a7", "defaultRpcEndpointIndex": 0, "name": "Sepolia", "nativeCurrency": "SepoliaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "sepolia", "type": "infura", "url": "https://sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe705": Object { - "blockExplorerUrls": Array [], + "0xe705": { + "blockExplorerUrls": [], "chainId": "0xe705", "defaultRpcEndpointIndex": 0, "name": "Linea Sepolia", "nativeCurrency": "LineaETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-sepolia", "type": "infura", "url": "https://linea-sepolia.infura.io/v3/{infuraProjectId}", }, ], }, - "0xe708": Object { - "blockExplorerUrls": Array [], + "0xe708": { + "blockExplorerUrls": [], "chainId": "0xe708", "defaultRpcEndpointIndex": 0, "name": "Linea", "nativeCurrency": "ETH", - "rpcEndpoints": Array [ - Object { - "failoverUrls": Array [], + "rpcEndpoints": [ + { + "failoverUrls": [], "networkClientId": "linea-mainnet", "type": "infura", "url": "https://linea-mainnet.infura.io/v3/{infuraProjectId}", @@ -15384,7 +15384,7 @@ describe('NetworkController', () => { ], }, }, - "networksMetadata": Object {}, + "networksMetadata": {}, "selectedNetworkClientId": "mainnet", } `); @@ -16888,7 +16888,9 @@ async function waitForPublishedEvents({ } else { reject( new Error( - `Expected to receive ${expectedNumberOfEvents} ${String(eventType)} event(s), but received ${ + `Expected to receive ${expectedNumberOfEvents} ${String( + eventType, + )} event(s), but received ${ interestingEventPayloads.length } after ${timeBeforeAssumingNoMoreEvents}ms.\n\nAll payloads:\n\n${inspect( allEventPayloads, diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 789eedb6119..dfffd4303c9 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.1.1] + ### Changed - Bump `@metamask/keyring-api` from `^21.0.0` to `^21.5.0` ([#7857](https://github.com/MetaMask/core/pull/7857)) -- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.16.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.17.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/multichain-network-controller` from `3.0.2` to `3.0.3` ([#7897](https://github.com/MetaMask/core/pull/7897)) + +### Fixed + +- Override SLIP-44 for HyperEVM (chain ID 999) to 2457 so native asset identifier is `eip155:999/slip44:2457` instead of the incorrect value from chainid.network (chain collision with Wanchain) ([#7975](https://github.com/MetaMask/core/pull/7975)) ## [4.1.0] @@ -197,7 +204,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6028](https://github.com/MetaMask/core/pull/6028)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.1.1...HEAD +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.1.0...@metamask/network-enablement-controller@4.1.1 [4.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@4.0.0...@metamask/network-enablement-controller@4.1.0 [4.0.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@3.1.0...@metamask/network-enablement-controller@4.0.0 [3.1.0]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@3.0.0...@metamask/network-enablement-controller@3.1.0 diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index 09db4acf655..fcd1f105dbd 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/network-enablement-controller", - "version": "4.1.0", + "version": "4.1.1", "description": "Provides an interface to the currently enabled network using a MetaMask-compatible provider object", "keywords": [ "MetaMask", @@ -52,22 +52,21 @@ "@metamask/controller-utils": "^11.18.0", "@metamask/keyring-api": "^21.5.0", "@metamask/messenger": "^0.3.0", - "@metamask/multichain-network-controller": "^3.0.2", + "@metamask/multichain-network-controller": "^3.0.3", "@metamask/network-controller": "^29.0.0", "@metamask/slip44": "^4.3.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "reselect": "^5.1.1" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts index 00ba58fccaa..9ed6df8fd22 100644 --- a/packages/network-enablement-controller/src/NetworkEnablementController.test.ts +++ b/packages/network-enablement-controller/src/NetworkEnablementController.test.ts @@ -13,7 +13,6 @@ import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import { KnownCaipNamespace } from '@metamask/utils'; import type { CaipChainId, CaipNamespace, Hex } from '@metamask/utils'; -import { useFakeTimers } from 'sinon'; import { POPULAR_NETWORKS } from './constants'; import { NetworkEnablementController } from './NetworkEnablementController'; @@ -22,7 +21,7 @@ import type { NativeAssetIdentifiersMap, } from './NetworkEnablementController'; import { Slip44Service } from './services'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; // Known chainId mappings from chainid.network for mocking const chainIdToSlip44: Record = { @@ -142,10 +141,8 @@ const setupController = ({ }; describe('NetworkEnablementController', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); // Mock Slip44Service.getEvmSlip44 to avoid network calls jest .spyOn(Slip44Service, 'getEvmSlip44') @@ -155,7 +152,7 @@ describe('NetworkEnablementController', () => { }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); jest.restoreAllMocks(); }); @@ -215,7 +212,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ enabledNetworkMap: { @@ -272,7 +269,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Create expected nativeAssetIdentifiers without Linea const expectedNativeAssetIdentifiers = { @@ -334,7 +331,7 @@ describe('NetworkEnablementController', () => { } as TransactionMeta, // Simplified structure for testing }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // State should remain unchanged expect(controller.state).toStrictEqual(initialState); @@ -351,7 +348,7 @@ describe('NetworkEnablementController', () => { // Missing transactionMeta entirely }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // State should remain unchanged expect(controller.state).toStrictEqual(initialState); @@ -368,7 +365,7 @@ describe('NetworkEnablementController', () => { transactionMeta: null, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // State should remain unchanged expect(controller.state).toStrictEqual(initialState); @@ -379,7 +376,7 @@ describe('NetworkEnablementController', () => { transactionMeta: undefined, }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // State should still remain unchanged expect(controller.state).toStrictEqual(initialState); @@ -413,7 +410,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Create expected nativeAssetIdentifiers without Linea const expectedNativeAssetIdentifiersForFallback = { @@ -1382,7 +1379,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // The added network should be enabled (exclusive behavior of network addition) expect(controller.isNetworkEnabled('0x2')).toBe(true); @@ -1546,7 +1543,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ enabledNetworkMap: { @@ -1767,7 +1764,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(controller.state).toStrictEqual({ enabledNetworkMap: { @@ -2046,7 +2043,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Now it should be added but not enabled (keeps current selection in popular mode) expect(controller.isNetworkEnabled('0xa86a')).toBe(true); @@ -2087,7 +2084,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Bitcoin should be enabled, all others should be disabled due to exclusive behavior expect( @@ -2294,7 +2291,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Bitcoin testnet should be enabled, others should be disabled (exclusive behavior across all namespaces) expect(controller.isNetworkEnabled(BtcScope.Testnet)).toBe(true); @@ -2528,7 +2525,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Tron Nile should be enabled, others should be disabled (exclusive behavior across all namespaces) expect(controller.isNetworkEnabled(TrxScope.Nile)).toBe(true); @@ -2904,7 +2901,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Should switch to Avalanche (disable all others, enable Avalanche) expect(controller.isNetworkEnabled('0xa86a')).toBe(true); @@ -2937,7 +2934,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Should switch to the non-popular network (disable all others, enable new one) expect(controller.isNetworkEnabled('0x999')).toBe(true); @@ -2970,7 +2967,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Should keep current selection (add Polygon but don't enable it) expect(controller.isNetworkEnabled('0x89')).toBe(true); // Polygon enabled @@ -3004,7 +3001,7 @@ describe('NetworkEnablementController', () => { ], }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); // Should switch to Avalanche since we're not in popular networks mode (2 ≤ 2, not >2) expect(controller.isNetworkEnabled('0xa86a')).toBe(true); diff --git a/packages/network-enablement-controller/src/services/Slip44Service.test.ts b/packages/network-enablement-controller/src/services/Slip44Service.test.ts index 54588947ec5..1263cb59a52 100644 --- a/packages/network-enablement-controller/src/services/Slip44Service.test.ts +++ b/packages/network-enablement-controller/src/services/Slip44Service.test.ts @@ -201,7 +201,7 @@ describe('Slip44Service', () => { ]); // Request a chainId not in the response - const result = await Slip44Service.getEvmSlip44(999); + const result = await Slip44Service.getEvmSlip44(12345); expect(result).toBe(60); // Defaults to 60 (Ethereum) }); @@ -238,6 +238,24 @@ describe('Slip44Service', () => { expect(result).toBe(60); }); + it('returns override value for HyperEVM (chain 999) instead of chainid.network data', async () => { + // chainid.network returns slip44:1 for chain 999 (Wanchain collision) + mockFetchWithErrorHandling.mockResolvedValueOnce([ + { chainId: 999, slip44: 1 }, + ]); + + const result = await Slip44Service.getEvmSlip44(999); + + expect(result).toBe(2457); + }); + + it('returns override value for HyperEVM without fetching chainid.network', async () => { + const result = await Slip44Service.getEvmSlip44(999); + + expect(result).toBe(2457); + expect(mockFetchWithErrorHandling).not.toHaveBeenCalled(); + }); + it('filters out entries without slip44 field and defaults to 60', async () => { // Mock response with some entries missing slip44 mockFetchWithErrorHandling.mockResolvedValueOnce([ diff --git a/packages/network-enablement-controller/src/services/Slip44Service.ts b/packages/network-enablement-controller/src/services/Slip44Service.ts index 9c3ea2a8b3a..affebea52c7 100644 --- a/packages/network-enablement-controller/src/services/Slip44Service.ts +++ b/packages/network-enablement-controller/src/services/Slip44Service.ts @@ -50,6 +50,14 @@ type Slip44Data = Record; * @see https://github.com/satoshilabs/slips/blob/master/slip-0044.md * @see https://chainid.network/chains.json */ +/** + * Manual overrides for EVM chain IDs where chainid.network returns + * an incorrect SLIP-44 value due to chain ID collisions. + */ +const EVM_SLIP44_OVERRIDES: ReadonlyMap = new Map([ + [999, 2457], // HyperEVM — chainid.network returns 1 (Wanchain collision) +]); + export class Slip44Service { /** * Cache for chainId to slip44 lookups from chainid.network. @@ -138,6 +146,11 @@ export class Slip44Service { * ``` */ static async getEvmSlip44(chainId: number): Promise { + const override = EVM_SLIP44_OVERRIDES.get(chainId); + if (override !== undefined) { + return override; + } + // Ensure chain data is loaded await this.#fetchChainData(); diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index d4eba1445d3..a7ae98353ef 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Debounce `KeyringController:stateChange` handler to reduce redundant notification subscription calls during rapid account syncing ([#7980](https://github.com/MetaMask/core/pull/7980)) +- Filter out Product Account announcements notifications older than 3 months ([#7884](https://github.com/MetaMask/core/pull/7884)) + +## [22.0.0] + +### Changed + - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) - Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7713](https://github.com/MetaMask/core/pull/7713), [#7849](https://github.com/MetaMask/core/pull/7849)) - The dependencies moved are: @@ -19,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. - Modified background push utilities to handle more edgecases and not throw errors ([#7275](https://github.com/MetaMask/core/pull/7275)) - Bump `@metamask/controller-utils` from `^11.16.0` to `^11.18.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583)) +- Filter feature announcements older than 3 months ([#7884](https://github.com/MetaMask/core/pull/7884)) - Move notifications networks metadata to backend ([#7840](https://github.com/MetaMask/core/pull/7840)) ### Removed @@ -655,7 +663,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/notification-services-controller@21.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@22.0.0...HEAD +[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@21.0.0...@metamask/notification-services-controller@22.0.0 [21.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@20.0.0...@metamask/notification-services-controller@21.0.0 [20.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@19.0.0...@metamask/notification-services-controller@20.0.0 [19.0.0]: https://github.com/MetaMask/core/compare/@metamask/notification-services-controller@18.3.1...@metamask/notification-services-controller@19.0.0 diff --git a/packages/notification-services-controller/jest.environment.js b/packages/notification-services-controller/jest.environment.js index e70c931b98b..f67bb89df5d 100644 --- a/packages/notification-services-controller/jest.environment.js +++ b/packages/notification-services-controller/jest.environment.js @@ -1,4 +1,4 @@ -const JSDOMEnvironment = require('jest-environment-jsdom'); +const { TestEnvironment } = require('jest-environment-jsdom'); /** * ProfileSync SDK & Controllers depends on @noble/hashes, which as of 1.3.2 relies on the @@ -6,7 +6,7 @@ const JSDOMEnvironment = require('jest-environment-jsdom'); * * There are also EIP6963 utils that utilize window */ -class CustomTestEnvironment extends JSDOMEnvironment { +class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index d544f7f4a30..1c817ced25a 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": "21.0.0", + "version": "22.0.0", "description": "Manages New MetaMask decentralized Notification system", "keywords": [ "MetaMask", @@ -109,6 +109,7 @@ "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "firebase": "^11.2.0", + "lodash": "^4.17.21", "loglevel": "^1.8.1", "semver": "^7.6.3", "uuid": "^8.3.2" @@ -118,16 +119,17 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.14.191", "@types/readable-stream": "^2.3.0", "@types/semver": "^7", "contentful": "^10.15.0", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 01620f2c603..e8a9252ee87 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -15,7 +15,7 @@ import { AuthenticationController } from '@metamask/profile-sync-controller'; import log from 'loglevel'; import type nock from 'nock'; -import { ADDRESS_1, ADDRESS_2 } from './__fixtures__/mockAddresses'; +import { ADDRESS_1, ADDRESS_2, ADDRESS_3 } from './__fixtures__/mockAddresses'; import { mockGetOnChainNotificationsConfig, mockUpdateOnChainNotifications, @@ -33,6 +33,7 @@ import { } from './mocks/mock-feature-announcements'; import { createMockNotificationEthSent } from './mocks/mock-raw-notifications'; import NotificationServicesController, { + ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS, defaultState, } from './NotificationServicesController'; import type { @@ -118,57 +119,34 @@ describe('NotificationServicesController', () => { ); }; - const arrangeActAssertKeyringTest = async ( - controllerState?: Partial, - ): Promise<{ - act: (addresses: string[], assertion: () => void) => Promise; - mockEnable: jest.SpyInstance; - mockDisable: jest.SpyInstance; - }> => { - const mocks = arrangeMocks(); - const { messenger, globalMessenger, mockKeyringControllerGetState } = - mocks; - mockKeyringControllerGetState.mockReturnValue({ - isUnlocked: true, - keyrings: [ - { - accounts: [], - type: KeyringTypes.hd, - metadata: { - id: '123', - name: '', - }, - }, - ], - }); - - const controller = new NotificationServicesController({ - messenger, - env: { featureAnnouncements: featureAnnouncementsEnv }, - state: { - isNotificationServicesEnabled: true, - subscriptionAccountsSeen: [], - ...controllerState, - }, - }); - controller.init(); - - const mockEnable = jest - .spyOn(controller, 'enableAccounts') - .mockResolvedValue(); - const mockDisable = jest - .spyOn(controller, 'disableAccounts') - .mockResolvedValue(); - - const act = async ( - addresses: string[], - assertion: () => void, - ): Promise => { + describe('KeyringController:stateChange (debounced)', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const arrangeActAssertKeyringTest = async ( + controllerState?: Partial, + ): Promise<{ + act: (addresses: string[], assertion: () => void) => Promise; + actMultiple: ( + addressesEvents: string[][], + assertion: () => void, + ) => Promise; + mockEnable: jest.SpyInstance; + mockDisable: jest.SpyInstance; + }> => { + const mocks = arrangeMocks(); + const { messenger, globalMessenger, mockKeyringControllerGetState } = + mocks; mockKeyringControllerGetState.mockReturnValue({ isUnlocked: true, keyrings: [ { - accounts: addresses, + accounts: [], type: KeyringTypes.hd, metadata: { id: '123', @@ -178,77 +156,161 @@ describe('NotificationServicesController', () => { ], }); - await actPublishKeyringStateChange(globalMessenger, addresses); - await waitFor(() => { - assertion(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + state: { + isNotificationServicesEnabled: true, + subscriptionAccountsSeen: [], + ...controllerState, + }, }); + controller.init(); + await jest.advanceTimersByTimeAsync(ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS); + + const mockEnable = jest + .spyOn(controller, 'enableAccounts') + .mockResolvedValue(); + const mockDisable = jest + .spyOn(controller, 'disableAccounts') + .mockResolvedValue(); + + const mockKeyringState = (addresses: string[]): void => { + mockKeyringControllerGetState.mockReturnValue({ + isUnlocked: true, + keyrings: [ + { + accounts: addresses, + type: KeyringTypes.hd, + }, + ], + }); + }; + + const cleanup = (): void => { + mockEnable.mockClear(); + mockDisable.mockClear(); + }; + + const act = async ( + addresses: string[], + assertion: () => void, + ): Promise => { + mockKeyringState(addresses); + + await actPublishKeyringStateChange(globalMessenger, addresses); + await jest.advanceTimersByTimeAsync(ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS); + assertion(); + + // Cleanup mocks for next act/assert + cleanup(); + }; + + const actMultiple = async ( + addressesEvents: string[][], + assertion: () => void, + ): Promise => { + for (const addresses of addressesEvents) { + mockKeyringState(addresses); + await actPublishKeyringStateChange(globalMessenger, addresses); + } + + await jest.advanceTimersByTimeAsync(ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS); + assertion(); - // Clear mocks for next act/assert - mockEnable.mockClear(); - mockDisable.mockClear(); + // Cleanup mocks for next act/assert + cleanup(); + }; + + return { act, actMultiple, mockEnable, mockDisable }; }; - return { act, mockEnable, mockDisable }; - }; + it('event KeyringController:stateChange will not add or remove triggers when feature is disabled', async () => { + const { act, mockEnable, mockDisable } = + await arrangeActAssertKeyringTest({ + isNotificationServicesEnabled: false, + }); - it('event KeyringController:stateChange will not add or remove triggers when feature is disabled', async () => { - const { act, mockEnable, mockDisable } = - await arrangeActAssertKeyringTest({ - isNotificationServicesEnabled: false, + // listAccounts has a new address + await act([ADDRESS_1, ADDRESS_2], () => { + expect(mockEnable).not.toHaveBeenCalled(); + expect(mockDisable).not.toHaveBeenCalled(); }); - - // listAccounts has a new address - await act([ADDRESS_1, ADDRESS_2], () => { - expect(mockEnable).not.toHaveBeenCalled(); - expect(mockDisable).not.toHaveBeenCalled(); }); - }); - it('event KeyringController:stateChange will update notification triggers when keyring accounts change', async () => { - const { act, mockEnable, mockDisable } = - await arrangeActAssertKeyringTest({ - subscriptionAccountsSeen: [ADDRESS_1], + it('event KeyringController:stateChange will update notification triggers when keyring accounts change', async () => { + const { act, mockEnable, mockDisable } = + await arrangeActAssertKeyringTest({ + subscriptionAccountsSeen: [ADDRESS_1], + }); + + // Act - if list accounts has been seen, then will not update + await act([ADDRESS_1], () => { + expect(mockEnable).not.toHaveBeenCalled(); + expect(mockDisable).not.toHaveBeenCalled(); }); - // Act - if list accounts has been seen, then will not update - await act([ADDRESS_1], () => { - expect(mockEnable).not.toHaveBeenCalled(); - expect(mockDisable).not.toHaveBeenCalled(); - }); + // Act - if a new address in list, then will update + await act([ADDRESS_1, ADDRESS_2], () => { + expect(mockEnable).toHaveBeenCalled(); + expect(mockDisable).not.toHaveBeenCalled(); + }); - // Act - if a new address in list, then will update - await act([ADDRESS_1, ADDRESS_2], () => { - expect(mockEnable).toHaveBeenCalled(); - expect(mockDisable).not.toHaveBeenCalled(); - }); + // Act - if the list doesn't have an address, then we need to delete + await act([ADDRESS_2], () => { + expect(mockEnable).not.toHaveBeenCalled(); + expect(mockDisable).toHaveBeenCalled(); + }); - // Act - if the list doesn't have an address, then we need to delete - await act([ADDRESS_2], () => { - expect(mockEnable).not.toHaveBeenCalled(); - expect(mockDisable).toHaveBeenCalled(); + // If the address is added back to the list, we will perform an update + await act([ADDRESS_1, ADDRESS_2], () => { + expect(mockEnable).toHaveBeenCalled(); + expect(mockDisable).not.toHaveBeenCalled(); + }); }); - // If the address is added back to the list, we will perform an update - await act([ADDRESS_1, ADDRESS_2], () => { - expect(mockEnable).toHaveBeenCalled(); - expect(mockDisable).not.toHaveBeenCalled(); - }); - }); + it('event KeyringController:stateChange will update only once when if the number of keyring accounts do not change', async () => { + const { act, mockEnable, mockDisable } = + await arrangeActAssertKeyringTest(); - it('event KeyringController:stateChange will update only once when if the number of keyring accounts do not change', async () => { - const { act, mockEnable, mockDisable } = - await arrangeActAssertKeyringTest(); + // Act - First list of items, so will update + await act([ADDRESS_1, ADDRESS_2], () => { + expect(mockEnable).toHaveBeenCalled(); + expect(mockDisable).not.toHaveBeenCalled(); + }); - // Act - First list of items, so will update - await act([ADDRESS_1, ADDRESS_2], () => { - expect(mockEnable).toHaveBeenCalled(); - expect(mockDisable).not.toHaveBeenCalled(); + // Act - Since number of addresses in keyring has not changed, will not update + await act([ADDRESS_1, ADDRESS_2], () => { + expect(mockEnable).not.toHaveBeenCalled(); + expect(mockDisable).not.toHaveBeenCalled(); + }); }); - // Act - Since number of addresses in keyring has not changed, will not update - await act([ADDRESS_1, ADDRESS_2], () => { - expect(mockEnable).not.toHaveBeenCalled(); - expect(mockDisable).not.toHaveBeenCalled(); + it('event KeyringController:stateChange will only update notifications once when the number of keyring accounts changes multiple times', async () => { + const { actMultiple, mockEnable, mockDisable } = + await arrangeActAssertKeyringTest(); + + await actMultiple( + [ + // Event 1 + [ADDRESS_1], + + // Event 2 + [ADDRESS_1, ADDRESS_2], + + // Event 3 + [ADDRESS_1, ADDRESS_2, ADDRESS_3], + ], + () => { + expect(mockEnable).toHaveBeenCalledTimes(1); + expect(mockEnable).toHaveBeenCalledWith([ + ADDRESS_1, + ADDRESS_2, + ADDRESS_3, + ]); + expect(mockDisable).not.toHaveBeenCalled(); + }, + ); }); }); @@ -1358,12 +1420,12 @@ describe('NotificationServicesController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "metamaskNotificationsList": Array [], - "metamaskNotificationsReadList": Array [], - "subscriptionAccountsSeen": Array [], + { + "metamaskNotificationsList": [], + "metamaskNotificationsReadList": [], + "subscriptionAccountsSeen": [], } - `); + `); }); it('includes expected state in state logs', () => { @@ -1380,14 +1442,14 @@ describe('NotificationServicesController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "isFeatureAnnouncementsEnabled": false, - "isMetamaskNotificationsFeatureSeen": false, - "isNotificationServicesEnabled": false, - "metamaskNotificationsList": Array [], - "subscriptionAccountsSeen": Array [], - } - `); + { + "isFeatureAnnouncementsEnabled": false, + "isMetamaskNotificationsFeatureSeen": false, + "isNotificationServicesEnabled": false, + "metamaskNotificationsList": [], + "subscriptionAccountsSeen": [], + } + `); }); it('persists expected state', () => { @@ -1404,13 +1466,13 @@ describe('NotificationServicesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "isFeatureAnnouncementsEnabled": false, "isMetamaskNotificationsFeatureSeen": false, "isNotificationServicesEnabled": false, - "metamaskNotificationsList": Array [], - "metamaskNotificationsReadList": Array [], - "subscriptionAccountsSeen": Array [], + "metamaskNotificationsList": [], + "metamaskNotificationsReadList": [], + "subscriptionAccountsSeen": [], } `); }); @@ -1429,17 +1491,17 @@ describe('NotificationServicesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "isCheckingAccountsPresence": false, "isFeatureAnnouncementsEnabled": false, "isFetchingMetamaskNotifications": false, "isMetamaskNotificationsFeatureSeen": false, "isNotificationServicesEnabled": false, "isUpdatingMetamaskNotifications": false, - "isUpdatingMetamaskNotificationsAccount": Array [], - "metamaskNotificationsList": Array [], - "metamaskNotificationsReadList": Array [], - "subscriptionAccountsSeen": Array [], + "isUpdatingMetamaskNotificationsAccount": [], + "metamaskNotificationsList": [], + "metamaskNotificationsReadList": [], + "subscriptionAccountsSeen": [], } `); }); diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index 00dc9cdc377..1d75602191d 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -19,6 +19,7 @@ import type { import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { assert } from '@metamask/utils'; +import { debounce } from 'lodash'; import log from 'loglevel'; import type { NormalisedAPINotification } from '.'; @@ -52,6 +53,8 @@ import type { // Unique name for the controller const controllerName = 'NotificationServicesController'; +export const ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS = 1000; + /** * State shape for NotificationServicesController */ @@ -478,11 +481,11 @@ export default class NotificationServicesController extends BaseController< * And call effects to subscribe/unsubscribe to notifications. */ subscribe: (): void => { - this.messenger.subscribe( - 'KeyringController:stateChange', - // Using void return for async callback - result is intentionally ignored - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async (totalAccounts, prevTotalAccounts): Promise => { + const debouncedUpdateAccountNotifications = debounce( + async ( + totalAccounts?: number, + prevTotalAccounts?: number, + ): Promise => { const hasTotalAccountsChanged = totalAccounts !== prevTotalAccounts; if ( !this.state.isNotificationServicesEnabled || @@ -503,7 +506,15 @@ export default class NotificationServicesController extends BaseController< } await Promise.allSettled(promises); }, - (state: KeyringControllerState) => { + ACCOUNTS_UPDATE_DEBOUNCE_TIME_MS, + ); + + this.messenger.subscribe( + 'KeyringController:stateChange', + // Using void return for async callback - result is intentionally ignored + // eslint-disable-next-line @typescript-eslint/no-misused-promises + debouncedUpdateAccountNotifications, + (state: KeyringControllerState): number => { return ( state?.keyrings?.flatMap?.((keyring) => keyring.accounts)?.length ?? 0 diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts index d555aa87288..d4d0a1d1501 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockAddresses.ts @@ -1,2 +1,3 @@ export const ADDRESS_1 = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; export const ADDRESS_2 = '0x0B3EAEd916519668491dB56c612Ff9B919288b65'; +export const ADDRESS_3 = '0x0B3EAEd916519668491dB56c612Ff9B919288b66'; diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-feature-announcements.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-feature-announcements.ts index 5c510c49c8a..63c6258964b 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-feature-announcements.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-feature-announcements.ts @@ -30,8 +30,12 @@ export function createMockFeatureAnnouncementAPIResult(): ContentfulResult { }, id: '1ABRmHaNCgmxROKXXLXsMu', type: 'Entry', - createdAt: '2024-04-09T13:24:01.872Z', - updatedAt: '2024-04-09T13:24:01.872Z', + createdAt: new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000, // 30 days ago + ).toISOString(), + updatedAt: new Date( + Date.now() - 30 * 24 * 60 * 60 * 1000, // 30 days ago + ).toISOString(), environment: { sys: { id: 'master', 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 a8f68a007ed..f2663124d4e 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,4 +1,5 @@ import { + ContentfulResult, getFeatureAnnouncementNotifications, getFeatureAnnouncementUrl, } from './feature-announcements'; @@ -74,6 +75,47 @@ describe('Feature Announcement Notifications', () => { expect(notifications).toStrictEqual([]); }); + describe('max age filter (exclude announcements older than 3 months)', () => { + const mockResultWithAge = (monthsAgo: number): ContentfulResult => { + const limitDate = new Date(); + limitDate.setMonth(limitDate.getMonth() - monthsAgo); + + const apiResult = createMockFeatureAnnouncementAPIResult(); + Object.assign(apiResult.items?.[0]?.sys ?? {}, { + updatedAt: limitDate.toISOString(), + }); + return apiResult; + }; + + it('filters out announcements older than 3 months', async () => { + const mockEndpoint = mockFetchFeatureAnnouncementNotifications({ + status: 200, + body: mockResultWithAge(4), + }); + + const notifications = await getFeatureAnnouncementNotifications( + featureAnnouncementsEnv, + ); + + mockEndpoint.done(); + expect(notifications).toHaveLength(0); + }); + + it('includes announcements within the last 3 months', async () => { + const mockEndpoint = mockFetchFeatureAnnouncementNotifications({ + status: 200, + body: mockResultWithAge(1), + }); + + const notifications = await getFeatureAnnouncementNotifications( + featureAnnouncementsEnv, + ); + + mockEndpoint.done(); + expect(notifications).toHaveLength(1); + }); + }); + it('should fetch entries from Contentful and return formatted notifications', async () => { const mockEndpoint = mockFetchFeatureAnnouncementNotifications({ status: 200, 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 ddd0c9cf64c..bd84e3ebe41 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/feature-announcements.ts @@ -17,6 +17,9 @@ import type { import type { INotification } from '../types/notification/notification'; import { isVersionInBounds } from '../utils/isVersionInBounds'; +// Feature announcements older than this (by sys.updatedAt) are excluded from the feed. +const FEATURE_ANNOUNCEMENT_MAX_AGE_MONTHS = 3; + const DEFAULT_SPACE_ID = ':space_id'; const DEFAULT_ACCESS_TOKEN = ':access_token'; const DEFAULT_CLIENT_ID = ':client_id'; @@ -103,72 +106,81 @@ const fetchFeatureAnnouncementNotifications = async ( const contentfulNotifications = data?.items ?? []; const rawNotifications: FeatureAnnouncementRawNotification[] = - contentfulNotifications.map((item: TypeFeatureAnnouncement) => { - const { fields } = item; - const imageFields = fields.image - ? (findIncludedItem(fields.image.sys.id) as ImageFields['fields']) - : undefined; - - const externalLinkFields = fields.externalLink - ? (findIncludedItem( - fields.externalLink.sys.id, - ) as TypeExternalLinkFields['fields']) - : undefined; - const portfolioLinkFields = fields.portfolioLink - ? (findIncludedItem( - fields.portfolioLink.sys.id, - ) as TypePortfolioLinkFields['fields']) - : undefined; - const extensionLinkFields = fields.extensionLink - ? (findIncludedItem( - fields.extensionLink.sys.id, - ) as TypeExtensionLinkFields['fields']) - : undefined; - const mobileLinkFields = fields.mobileLink - ? (findIncludedItem( - fields.mobileLink.sys.id, - ) as TypeMobileLinkFields['fields']) - : undefined; - - const notification: FeatureAnnouncementRawNotification = { - type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, - createdAt: new Date(item.sys.createdAt).toString(), - data: { - id: fields.id, - category: fields.category, - title: fields.title, - longDescription: documentToHtmlString(fields.longDescription), - shortDescription: fields.shortDescription, - image: { - title: imageFields?.title, - description: imageFields?.description, - url: imageFields?.file?.url ?? '', - }, - externalLink: externalLinkFields && { - externalLinkText: externalLinkFields?.externalLinkText, - externalLinkUrl: externalLinkFields?.externalLinkUrl, - }, - portfolioLink: portfolioLinkFields && { - portfolioLinkText: portfolioLinkFields?.portfolioLinkText, - portfolioLinkUrl: portfolioLinkFields?.portfolioLinkUrl, - }, - extensionLink: extensionLinkFields && { - extensionLinkText: extensionLinkFields?.extensionLinkText, - extensionLinkRoute: extensionLinkFields?.extensionLinkRoute, + contentfulNotifications + .filter((item: TypeFeatureAnnouncement) => { + const updatedAt = new Date(item.sys.updatedAt); + const limitDate = new Date(); + limitDate.setMonth( + limitDate.getMonth() - FEATURE_ANNOUNCEMENT_MAX_AGE_MONTHS, + ); + return updatedAt > limitDate; + }) + .map((item: TypeFeatureAnnouncement) => { + const { fields } = item; + const imageFields = fields.image + ? (findIncludedItem(fields.image.sys.id) as ImageFields['fields']) + : undefined; + + const externalLinkFields = fields.externalLink + ? (findIncludedItem( + fields.externalLink.sys.id, + ) as TypeExternalLinkFields['fields']) + : undefined; + const portfolioLinkFields = fields.portfolioLink + ? (findIncludedItem( + fields.portfolioLink.sys.id, + ) as TypePortfolioLinkFields['fields']) + : undefined; + const extensionLinkFields = fields.extensionLink + ? (findIncludedItem( + fields.extensionLink.sys.id, + ) as TypeExtensionLinkFields['fields']) + : undefined; + const mobileLinkFields = fields.mobileLink + ? (findIncludedItem( + fields.mobileLink.sys.id, + ) as TypeMobileLinkFields['fields']) + : undefined; + + const notification: FeatureAnnouncementRawNotification = { + type: TRIGGER_TYPES.FEATURES_ANNOUNCEMENT, + createdAt: new Date(item.sys.createdAt).toString(), + data: { + id: fields.id, + category: fields.category, + title: fields.title, + longDescription: documentToHtmlString(fields.longDescription), + shortDescription: fields.shortDescription, + image: { + title: imageFields?.title, + description: imageFields?.description, + url: imageFields?.file?.url ?? '', + }, + externalLink: externalLinkFields && { + externalLinkText: externalLinkFields?.externalLinkText, + externalLinkUrl: externalLinkFields?.externalLinkUrl, + }, + portfolioLink: portfolioLinkFields && { + portfolioLinkText: portfolioLinkFields?.portfolioLinkText, + portfolioLinkUrl: portfolioLinkFields?.portfolioLinkUrl, + }, + extensionLink: extensionLinkFields && { + extensionLinkText: extensionLinkFields?.extensionLinkText, + extensionLinkRoute: extensionLinkFields?.extensionLinkRoute, + }, + mobileLink: mobileLinkFields && { + mobileLinkText: mobileLinkFields?.mobileLinkText, + mobileLinkUrl: mobileLinkFields?.mobileLinkUrl, + }, + extensionMinimumVersionNumber: fields.extensionMinimumVersionNumber, + mobileMinimumVersionNumber: fields.mobileMinimumVersionNumber, + extensionMaximumVersionNumber: fields.extensionMaximumVersionNumber, + mobileMaximumVersionNumber: fields.mobileMaximumVersionNumber, }, - mobileLink: mobileLinkFields && { - mobileLinkText: mobileLinkFields?.mobileLinkText, - mobileLinkUrl: mobileLinkFields?.mobileLinkUrl, - }, - extensionMinimumVersionNumber: fields.extensionMinimumVersionNumber, - mobileMinimumVersionNumber: fields.mobileMinimumVersionNumber, - extensionMaximumVersionNumber: fields.extensionMaximumVersionNumber, - mobileMaximumVersionNumber: fields.mobileMaximumVersionNumber, - }, - }; - - return notification; - }); + }; + + return notification; + }); const versionKeys = { extension: { diff --git a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts index 5c93260ffb0..394aec2973f 100644 --- a/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesPushController/NotificationServicesPushController.test.ts @@ -291,12 +291,12 @@ describe('NotificationServicesPushController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "fcmToken": "", "isPushEnabled": true, "isUpdatingFCMToken": false, } - `); + `); }); it('includes expected state in state logs', () => { @@ -309,10 +309,10 @@ describe('NotificationServicesPushController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "isPushEnabled": true, } - `); + `); }); it('persists expected state', () => { @@ -325,11 +325,11 @@ describe('NotificationServicesPushController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "fcmToken": "", "isPushEnabled": true, } - `); + `); }); it('includes expected state in UI', () => { @@ -342,12 +342,12 @@ describe('NotificationServicesPushController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "fcmToken": "", "isPushEnabled": true, "isUpdatingFCMToken": false, } - `); + `); }); }); }); diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index d11177791cf..4add56ef915 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -63,11 +63,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index a1c15c22fe3..a8ea599ad20 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -6400,8 +6400,8 @@ describe('PermissionController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "subjects": Object {}, + { + "subjects": {}, } `); }); @@ -6416,8 +6416,8 @@ describe('PermissionController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "subjects": Object {}, + { + "subjects": {}, } `); }); @@ -6432,8 +6432,8 @@ describe('PermissionController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "subjects": Object {}, + { + "subjects": {}, } `); }); @@ -6448,8 +6448,8 @@ describe('PermissionController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "subjects": Object {}, + { + "subjects": {}, } `); }); diff --git a/packages/permission-controller/src/SubjectMetadataController.test.ts b/packages/permission-controller/src/SubjectMetadataController.test.ts index 49b3e9fcb12..b58532db3f5 100644 --- a/packages/permission-controller/src/SubjectMetadataController.test.ts +++ b/packages/permission-controller/src/SubjectMetadataController.test.ts @@ -394,7 +394,7 @@ describe('SubjectMetadataController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -411,8 +411,8 @@ describe('SubjectMetadataController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "subjectMetadata": Object {}, + { + "subjectMetadata": {}, } `); }); @@ -431,8 +431,8 @@ describe('SubjectMetadataController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "subjectMetadata": Object {}, + { + "subjectMetadata": {}, } `); }); @@ -451,8 +451,8 @@ describe('SubjectMetadataController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "subjectMetadata": Object {}, + { + "subjectMetadata": {}, } `); }); diff --git a/packages/permission-log-controller/package.json b/packages/permission-log-controller/package.json index ea2eba7e070..f5a63d8e62e 100644 --- a/packages/permission-log-controller/package.json +++ b/packages/permission-log-controller/package.json @@ -57,13 +57,13 @@ "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", "@types/deep-freeze-strict": "^1.1.0", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nanoid": "^3.3.8", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts index 08f4e9f7124..acab9ecbc70 100644 --- a/packages/permission-log-controller/tests/PermissionLogController.test.ts +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -94,7 +94,7 @@ const mockNext = }; const initClock = (): void => { - jest.useFakeTimers('modern'); + jest.useFakeTimers(); jest.setSystemTime(new Date(1)); }; @@ -858,7 +858,7 @@ describe('PermissionLogController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -873,9 +873,9 @@ describe('PermissionLogController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "permissionActivityLog": Array [], - "permissionHistory": Object {}, + { + "permissionActivityLog": [], + "permissionHistory": {}, } `); }); @@ -892,8 +892,8 @@ describe('PermissionLogController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "permissionHistory": Object {}, + { + "permissionHistory": {}, } `); }); @@ -910,8 +910,8 @@ describe('PermissionLogController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "permissionHistory": Object {}, + { + "permissionHistory": {}, } `); }); diff --git a/packages/permission-log-controller/tests/index.test.ts b/packages/permission-log-controller/tests/index.test.ts index 34ea815b874..c33564c9c2b 100644 --- a/packages/permission-log-controller/tests/index.test.ts +++ b/packages/permission-log-controller/tests/index.test.ts @@ -3,7 +3,7 @@ import * as allExports from '../src'; describe('Package exports', () => { it('has expected exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ + [ "PermissionLogController", ] `); diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 8cb20f6bb97..a25557ab739 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -56,11 +56,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/perps-controller/src/PerpsController.test.ts b/packages/perps-controller/src/PerpsController.test.ts index 713d216f633..2cdc4a0b172 100644 --- a/packages/perps-controller/src/PerpsController.test.ts +++ b/packages/perps-controller/src/PerpsController.test.ts @@ -24,7 +24,7 @@ describe('PerpsController', () => { it('fills in missing initial state with defaults', async () => { await withController(({ controller }) => { - expect(controller.state).toMatchInlineSnapshot(`Object {}`); + expect(controller.state).toMatchInlineSnapshot(`{}`); }); }); }); @@ -38,7 +38,7 @@ describe('PerpsController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -50,7 +50,7 @@ describe('PerpsController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -62,7 +62,7 @@ describe('PerpsController', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -74,7 +74,7 @@ describe('PerpsController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 730147b8d59..11313f6a1b4 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [16.3.0] + +### Added + +- Add support for Solana (`solana`) as a chain identifier in `bulkScanTokens` ([#7923](https://github.com/MetaMask/core/pull/7923)) + - Non-EVM chain names (e.g. `'solana'`) can now be passed as `chainId` in addition to hex EVM chain IDs + - Token address casing is preserved for non-EVM chains (EVM addresses continue to be lowercased) +- Export `TokenScanResultType` as a runtime value (previously type-only) ([#7923](https://github.com/MetaMask/core/pull/7923)) +- Export `BulkTokenScanResponse` type ([#7923](https://github.com/MetaMask/core/pull/7923)) + +### Changed + +- Bump `@metamask/transaction-controller` from `62.16.0` to `62.17.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) + ## [16.2.0] ### Added @@ -514,7 +528,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 All changes listed after this point were applied to this package following the monorepo conversion. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.3.0...HEAD +[16.3.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.2.0...@metamask/phishing-controller@16.3.0 [16.2.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.1.0...@metamask/phishing-controller@16.2.0 [16.1.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@16.0.0...@metamask/phishing-controller@16.1.0 [16.0.0]: https://github.com/MetaMask/core/compare/@metamask/phishing-controller@15.0.1...@metamask/phishing-controller@16.0.0 diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 3eeb77abdc5..e778d40c5f4 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/phishing-controller", - "version": "16.2.0", + "version": "16.3.0", "description": "Maintains a periodically updated list of approved and unapproved website origins", "keywords": [ "MetaMask", @@ -51,7 +51,7 @@ "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/messenger": "^0.3.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@noble/hashes": "^1.8.0", "@types/punycode": "^2.1.0", "ethereum-cryptography": "^2.1.2", @@ -61,13 +61,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/phishing-controller/src/BulkTokenScan.test.ts b/packages/phishing-controller/src/BulkTokenScan.test.ts index 9755d0218ce..0446f4536e2 100644 --- a/packages/phishing-controller/src/BulkTokenScan.test.ts +++ b/packages/phishing-controller/src/BulkTokenScan.test.ts @@ -6,7 +6,6 @@ import type { MockAnyNamespace, } from '@metamask/messenger'; import nock, { cleanAll } from 'nock'; -import sinon from 'sinon'; import { PhishingController, @@ -118,7 +117,6 @@ describe('PhishingController - Bulk Token Scanning', () => { }); afterEach(() => { - sinon.restore(); cleanAll(); consoleErrorSpy.mockRestore(); consoleWarnSpy.mockRestore(); @@ -617,6 +615,113 @@ describe('PhishingController - Bulk Token Scanning', () => { }); }); + describe('non-EVM chains', () => { + it('should work with Solana chain name', async () => { + const tokens = [ + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + 'SpamTokenAddress', + ]; + const mockApiResponse: TokenScanApiResponse = { + results: { + Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr: { + result_type: TokenScanResultType.Benign, + }, + SpamTokenAddress: { + result_type: TokenScanResultType.Spam, + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'solana', + tokens, + }) + .reply(200, mockApiResponse); + + const request: BulkTokenScanRequest = { + chainId: 'solana', + tokens, + }; + + const result = await controller.bulkScanTokens(request); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual({ + Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr: { + result_type: TokenScanResultType.Benign, + chain: 'solana', + address: 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + }, + SpamTokenAddress: { + result_type: TokenScanResultType.Spam, + chain: 'solana', + address: 'SpamTokenAddress', + }, + }); + }); + + it('should preserve address casing for Solana tokens', async () => { + const originalCaseToken = + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + const mockApiResponse: TokenScanApiResponse = { + results: { + [originalCaseToken]: { + result_type: TokenScanResultType.Benign, + }, + }, + }; + + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT, { + chain: 'solana', + tokens: [originalCaseToken], + }) + .reply(200, mockApiResponse); + + const result = await controller.bulkScanTokens({ + chainId: 'solana', + tokens: [originalCaseToken], + }); + + expect(scope.isDone()).toBe(true); + // Result key should preserve original casing + expect(result[originalCaseToken]).toBeDefined(); + expect(result[originalCaseToken.toLowerCase()]).toBeUndefined(); + }); + + it('should cache Solana token results', async () => { + const token = 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + const mockApiResponse: TokenScanApiResponse = { + results: { + [token]: { + result_type: TokenScanResultType.Benign, + }, + }, + }; + + // First call should hit the API + const scope1 = nock(SECURITY_ALERTS_BASE_URL) + .post(TOKEN_BULK_SCANNING_ENDPOINT) + .reply(200, mockApiResponse); + + const result1 = await controller.bulkScanTokens({ + chainId: 'solana', + tokens: [token], + }); + expect(scope1.isDone()).toBe(true); + + // Second call should use cache (no additional API call) + const result2 = await controller.bulkScanTokens({ + chainId: 'solana', + tokens: [token], + }); + + expect(result1).toStrictEqual(result2); + expect(result2[token]).toBeDefined(); + }); + }); + describe('maximum tokens boundary', () => { it('should successfully process exactly 100 tokens', async () => { const tokens = Array.from( diff --git a/packages/phishing-controller/src/CacheManager.test.ts b/packages/phishing-controller/src/CacheManager.test.ts index 0418112cbf3..3f0d3fe42e4 100644 --- a/packages/phishing-controller/src/CacheManager.test.ts +++ b/packages/phishing-controller/src/CacheManager.test.ts @@ -1,19 +1,16 @@ -import sinon from 'sinon'; - import { CacheManager } from './CacheManager'; import * as utils from './utils'; describe('CacheManager', () => { - let clock: sinon.SinonFakeTimers; - let updateStateSpy: sinon.SinonSpy; + let updateStateSpy: jest.Mock; let cache: CacheManager<{ value: string }>; beforeEach(() => { - clock = sinon.useFakeTimers(); - sinon - .stub(utils, 'fetchTimeNow') - .callsFake(() => Math.floor(Date.now() / 1000)); - updateStateSpy = sinon.spy(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + jest + .spyOn(utils, 'fetchTimeNow') + .mockImplementation(() => Math.floor(Date.now() / 1000)); + updateStateSpy = jest.fn(); cache = new CacheManager<{ value: string }>({ cacheTTL: 300, // 5 minutes maxCacheSize: 3, @@ -22,7 +19,8 @@ describe('CacheManager', () => { }); afterEach(() => { - sinon.restore(); + jest.useRealTimers(); + jest.restoreAllMocks(); }); describe('constructor', () => { @@ -69,7 +67,7 @@ describe('CacheManager', () => { cache.set('key1', { value: 'value1' }); // Fast forward time past TTL - clock.tick(301 * 1000); + jest.advanceTimersByTime(301 * 1000); expect(cache.get('key1')).toBeUndefined(); }); @@ -89,7 +87,7 @@ describe('CacheManager', () => { it('should call updateState when adding entries', () => { cache.set('key1', { value: 'value1' }); - expect(updateStateSpy.calledOnce).toBe(true); + expect(updateStateSpy).toHaveBeenCalledTimes(1); }); it('should evict oldest entries when cache exceeds max size', () => { @@ -118,9 +116,9 @@ describe('CacheManager', () => { it('should call updateState when deleting entries', () => { cache.set('key1', { value: 'value1' }); - updateStateSpy.resetHistory(); + updateStateSpy.mockClear(); cache.delete('key1'); - expect(updateStateSpy.calledOnce).toBe(true); + expect(updateStateSpy).toHaveBeenCalledTimes(1); }); }); @@ -135,9 +133,9 @@ describe('CacheManager', () => { it('should call updateState', () => { cache.set('key1', { value: 'value1' }); - updateStateSpy.resetHistory(); + updateStateSpy.mockClear(); cache.clear(); - expect(updateStateSpy.calledOnce).toBe(true); + expect(updateStateSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 42a286794f7..9fd036ffac0 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -7,7 +7,6 @@ import type { } from '@metamask/messenger'; import { strict as assert } from 'assert'; import nock, { cleanAll, isDone, pendingMocks } from 'nock'; -import sinon from 'sinon'; import { ListNames, @@ -116,7 +115,7 @@ function getPhishingController(options?: Partial) { describe('PhishingController', () => { afterEach(() => { - sinon.restore(); + jest.useRealTimers(); cleanAll(); }); @@ -225,7 +224,7 @@ describe('PhishingController', () => { }); it('should not re-request when an update is in progress', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const nockScope = nock(PHISHING_CONFIG_BASE_URL) .get(`${METAMASK_HOTLIST_DIFF_FILE}/${1}`) .delay(500) // delay promise resolution to generate "pending" state that lasts long enough to test. @@ -263,7 +262,7 @@ describe('PhishingController', () => { ], }, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); const pendingUpdate = controller.updateHotlist(); expect(controller.isHotlistOutOfDate()).toBe(true); @@ -318,11 +317,11 @@ describe('PhishingController', () => { }); it('should not have stalelist be out of date immediately after maybeUpdateState is called', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); await controller.maybeUpdateState(); expect(controller.isStalelistOutOfDate()).toBe(false); @@ -330,36 +329,36 @@ describe('PhishingController', () => { }); it('should not be out of date after maybeUpdateStalelist is called but before refresh interval has passed', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); await controller.maybeUpdateState(); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isStalelistOutOfDate()).toBe(false); expect(nockScope.isDone()).toBe(true); }); it('should still be out of date while update is in progress', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); // do not wait const maybeUpdatePhisingListPromise = controller.maybeUpdateState(); expect(controller.isStalelistOutOfDate()).toBe(true); await maybeUpdatePhisingListPromise; expect(controller.isStalelistOutOfDate()).toBe(false); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); expect(nockScope.isDone()).toBe(true); }); it('should call update only if it is out of date, otherwise it should not call update', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); @@ -383,7 +382,7 @@ describe('PhishingController', () => { type: PhishingDetectorResultType.All, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); await controller.maybeUpdateState(); expect( @@ -425,12 +424,15 @@ describe('PhishingController', () => { }, ], }); - const clock = sinon.useFakeTimers(50); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: 50, + }); const controller = getPhishingController({ hotlistRefreshInterval: 10, stalelistRefreshInterval: 50, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isHotlistOutOfDate()).toBe(true); await controller.maybeUpdateState(); expect(controller.isHotlistOutOfDate()).toBe(false); @@ -444,11 +446,11 @@ describe('PhishingController', () => { recentlyRemoved: [], lastFetchedAt: 1, }); - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); await controller.maybeUpdateState(); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); @@ -509,8 +511,8 @@ describe('PhishingController', () => { }); // Force the stalelist to be out of date and trigger update - const clock = sinon.useFakeTimers(); - clock.tick(1000 * 10); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + jest.advanceTimersByTime(1000 * 10); await controller.maybeUpdateState(); @@ -532,13 +534,13 @@ describe('PhishingController', () => { }, ]); - clock.restore(); + jest.useRealTimers(); }); }); describe('isStalelistOutOfDate', () => { it('should not be out of date upon construction', () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); @@ -547,31 +549,31 @@ describe('PhishingController', () => { }); it('should not be out of date after some of the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isStalelistOutOfDate()).toBe(false); }); it('should be out of date after the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); }); it('should be out of date if the refresh interval has passed and an update is in progress', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); const pendingUpdate = controller.updateStalelist(); expect(controller.isStalelistOutOfDate()).toBe(true); @@ -581,7 +583,7 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were just updated', async () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); @@ -591,23 +593,23 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were recently updated', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); await controller.updateStalelist(); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isStalelistOutOfDate()).toBe(false); }); it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10, }); await controller.updateStalelist(); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isStalelistOutOfDate()).toBe(true); }); @@ -615,7 +617,7 @@ describe('PhishingController', () => { describe('isHotlistOutOfDate', () => { it('should not be out of date upon construction', () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); @@ -624,27 +626,27 @@ describe('PhishingController', () => { }); it('should not be out of date after some of the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isHotlistOutOfDate()).toBe(false); }); it('should be out of date after the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isHotlistOutOfDate()).toBe(true); }); it('should be out of date if the refresh interval has passed and an update is in progress', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, state: { @@ -663,7 +665,7 @@ describe('PhishingController', () => { ], }, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); const pendingUpdate = controller.updateHotlist(); expect(controller.isHotlistOutOfDate()).toBe(true); @@ -673,7 +675,7 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were just updated', async () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); @@ -683,23 +685,23 @@ describe('PhishingController', () => { }); it('should not be out of date if the phishing lists were recently updated', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); await controller.updateHotlist(); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isHotlistOutOfDate()).toBe(false); }); it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); await controller.updateHotlist(); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isHotlistOutOfDate()).toBe(true); }); @@ -707,7 +709,7 @@ describe('PhishingController', () => { describe('isC2DomainBlocklistOutOfDate', () => { it('should not be out of date upon construction', () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); @@ -716,31 +718,31 @@ describe('PhishingController', () => { }); it('should not be out of date after some of the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); }); it('should be out of date after the refresh interval has passed', () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); }); it('should be out of date if the refresh interval has passed and an update is in progress', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); const pendingUpdate = controller.updateC2DomainBlocklist(); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); @@ -750,7 +752,7 @@ describe('PhishingController', () => { }); it('should not be out of date if the C2 domain blocklist was just updated', async () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); @@ -760,30 +762,30 @@ describe('PhishingController', () => { }); it('should not be out of date if the C2 domain blocklist was recently updated', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); await controller.updateC2DomainBlocklist(); - clock.tick(1000 * 5); + jest.advanceTimersByTime(1000 * 5); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(false); }); it('should be out of date if the time elapsed since the last update equals the refresh interval', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); await controller.updateC2DomainBlocklist(); - clock.tick(1000 * 10); + jest.advanceTimersByTime(1000 * 10); expect(controller.isC2DomainBlocklistOutOfDate()).toBe(true); }); }); it('should be able to change the stalelistRefreshInterval', async () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ stalelistRefreshInterval: 10 }); controller.setStalelistRefreshInterval(0); @@ -791,7 +793,7 @@ describe('PhishingController', () => { }); it('should be able to change the hotlistRefreshInterval', async () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ hotlistRefreshInterval: 10, }); @@ -801,7 +803,7 @@ describe('PhishingController', () => { }); it('should be able to change the c2DomainBlocklistRefreshInterval', async () => { - sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); const controller = getPhishingController({ c2DomainBlocklistRefreshInterval: 10, }); @@ -1425,7 +1427,7 @@ describe('PhishingController', () => { describe('updateStalelist', () => { it('should update lists with addition to hotlist', async () => { - sinon.useFakeTimers(2); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 2 }); const exampleBlockedUrl = 'example-blocked-website.com'; const exampleRequestBlockedHash = '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; @@ -1482,7 +1484,7 @@ describe('PhishingController', () => { }); it('should update lists with removal diff from hotlist', async () => { - sinon.useFakeTimers(2); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 2 }); const exampleBlockedUrl = 'example-blocked-website.com'; const exampleRequestBlockedHash = '0415f1f12f07ddc4ef7e229da747c6c53a6a6474fbaf295a35d984ec0ece9455'; @@ -1693,7 +1695,10 @@ describe('PhishingController', () => { describe('an update is in progress', () => { it('should not fetch phishing lists again', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: 0, + }); const nockScope = nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .delay(100) @@ -1715,7 +1720,7 @@ describe('PhishingController', () => { const firstPromise = controller.updateStalelist(); const secondPromise = controller.updateStalelist(); - clock.tick(1000 * 100); + jest.advanceTimersByTime(1000 * 100); await firstPromise; await secondPromise; @@ -1726,7 +1731,10 @@ describe('PhishingController', () => { }); it('should wait until the in-progress update has completed', async () => { - const clock = sinon.useFakeTimers(); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: 0, + }); nock(PHISHING_CONFIG_BASE_URL) .get(METAMASK_STALELIST_FILE) .delay(100) @@ -1747,7 +1755,7 @@ describe('PhishingController', () => { const controller = getPhishingController(); const firstPromise = controller.updateStalelist(); const secondPromise = controller.updateStalelist(); - clock.tick(1000 * 99); + jest.advanceTimersByTime(1000 * 99); await expect(secondPromise).toNeverResolve(); @@ -2619,7 +2627,7 @@ describe('PhishingController', () => { describe('scanUrl', () => { let controller: PhishingController; - let clock: sinon.SinonFakeTimers; + const testUrl: string = 'https://example.com'; const mockResponse: PhishingDetectionScanResult = { hostname: 'example.com', @@ -2628,7 +2636,7 @@ describe('PhishingController', () => { beforeEach(() => { controller = getPhishingController(); - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); }); it('should return the scan result', async () => { @@ -2677,7 +2685,7 @@ describe('PhishingController', () => { .reply(200, {}); const promise = controller.scanUrl(testUrl); - clock.tick(8000); + jest.advanceTimersByTime(8000); const response = await promise; expect(response).toMatchObject({ hostname: '', @@ -2783,7 +2791,7 @@ describe('PhishingController', () => { describe('bulkScanUrls', () => { let controller: PhishingController; - let clock: sinon.SinonFakeTimers; + const testUrls: string[] = [ 'https://example1.com', 'https://example2.com', @@ -2809,11 +2817,11 @@ describe('PhishingController', () => { beforeEach(() => { controller = getPhishingController(); - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('should return the scan results for multiple URLs', async () => { @@ -2896,7 +2904,7 @@ describe('PhishingController', () => { .reply(200, {}); const promise = controller.bulkScanUrls(testUrls); - clock.tick(15000); + jest.advanceTimersByTime(15000); const response = await promise; expect(response).toStrictEqual({ results: {}, @@ -3075,7 +3083,9 @@ describe('PhishingController', () => { // First cache a result via scanUrl nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent('cached-example.com')}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + 'cached-example.com', + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3185,7 +3195,9 @@ describe('PhishingController', () => { // Set up nock for individual caching nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent('domain1.com')}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + 'domain1.com', + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3193,7 +3205,9 @@ describe('PhishingController', () => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent('domain2.com')}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + 'domain2.com', + )}`, ) .reply(200, { recommendedAction: RecommendedAction.Block, @@ -3218,7 +3232,7 @@ describe('PhishingController', () => { describe('scanAddress', () => { let controller: PhishingController; - let clock: sinon.SinonFakeTimers; + const testChainId = '0x1'; const testAddress = '0x1234567890123456789012345678901234567890'; const mockResponse: AddressScanResult = { @@ -3228,11 +3242,11 @@ describe('PhishingController', () => { beforeEach(() => { controller = getPhishingController(); - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('will return the scan result for a valid address', async () => { @@ -3286,7 +3300,7 @@ describe('PhishingController', () => { .reply(200, {}); const promise = controller.scanAddress(testChainId, testAddress); - clock.tick(5000); + jest.advanceTimersByTime(5000); const response = await promise; expect(response).toMatchObject({ result_type: AddressScanResultType.ErrorResult, @@ -3417,13 +3431,11 @@ describe('PhishingController', () => { }); describe('URL Scan Cache', () => { - let clock: sinon.SinonFakeTimers; - beforeEach(() => { - clock = sinon.useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); }); afterEach(() => { - sinon.restore(); + jest.useRealTimers(); cleanAll(); }); @@ -3435,7 +3447,9 @@ describe('URL Scan Cache', () => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3467,13 +3481,17 @@ describe('URL Scan Cache', () => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, }) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3486,12 +3504,12 @@ describe('URL Scan Cache', () => { await controller.scanUrl(`https://${testDomain}`); // Before TTL expires, should use cache - clock.tick((cacheTTL - 10) * 1000); + jest.advanceTimersByTime((cacheTTL - 10) * 1000); await controller.scanUrl(`https://${testDomain}`); expect(pendingMocks()).toHaveLength(1); // One mock remaining // After TTL expires, should fetch again - clock.tick(11 * 1000); + jest.advanceTimersByTime(11 * 1000); await controller.scanUrl(`https://${testDomain}`); expect(pendingMocks()).toHaveLength(0); // All mocks used }); @@ -3504,7 +3522,9 @@ describe('URL Scan Cache', () => { domains.forEach((domain) => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + domain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3514,7 +3534,9 @@ describe('URL Scan Cache', () => { // Setup a second request for the first domain nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domains[0])}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + domains[0], + )}`, ) .reply(200, { recommendedAction: RecommendedAction.Warn, @@ -3526,11 +3548,11 @@ describe('URL Scan Cache', () => { // Fill the cache await controller.scanUrl(`https://${domains[0]}`); - clock.tick(1000); // Ensure different timestamps + jest.advanceTimersByTime(1000); // Ensure different timestamps await controller.scanUrl(`https://${domains[1]}`); // This should evict the oldest entry (domain1) - clock.tick(1000); + jest.advanceTimersByTime(1000); await controller.scanUrl(`https://${domains[2]}`); // Now domain1 should not be in cache and require a new fetch @@ -3545,13 +3567,17 @@ describe('URL Scan Cache', () => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, }) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3579,13 +3605,17 @@ describe('URL Scan Cache', () => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, }) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3602,12 +3632,12 @@ describe('URL Scan Cache', () => { controller.setUrlScanCacheTTL(newTTL); // Before new TTL expires, should use cache - clock.tick((newTTL - 10) * 1000); + jest.advanceTimersByTime((newTTL - 10) * 1000); await controller.scanUrl(`https://${testDomain}`); expect(pendingMocks()).toHaveLength(1); // One mock remaining // After new TTL expires, should fetch again - clock.tick(11 * 1000); + jest.advanceTimersByTime(11 * 1000); await controller.scanUrl(`https://${testDomain}`); expect(pendingMocks()).toHaveLength(0); // All mocks used }); @@ -3626,7 +3656,9 @@ describe('URL Scan Cache', () => { domains.forEach((domain) => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + domain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3639,9 +3671,9 @@ describe('URL Scan Cache', () => { // Fill the cache to initial size await controller.scanUrl(`https://${domains[0]}`); - clock.tick(1000); // Ensure different timestamps + jest.advanceTimersByTime(1000); // Ensure different timestamps await controller.scanUrl(`https://${domains[1]}`); - clock.tick(1000); + jest.advanceTimersByTime(1000); await controller.scanUrl(`https://${domains[2]}`); // Verify initial cache size @@ -3665,11 +3697,15 @@ describe('URL Scan Cache', () => { nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(500, { error: 'Internal Server Error' }) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3696,11 +3732,15 @@ describe('URL Scan Cache', () => { // First mock a timeout/error response nock(PHISHING_DETECTION_BASE_URL) .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .replyWithError('connection timeout') .get( - `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent( + testDomain, + )}`, ) .reply(200, { recommendedAction: RecommendedAction.None, @@ -3745,7 +3785,7 @@ describe('URL Scan Cache', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -3758,7 +3798,7 @@ describe('URL Scan Cache', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "c2DomainBlocklistLastFetched": 0, "hotlistLastFetched": 0, "stalelistLastFetched": 0, @@ -3776,16 +3816,16 @@ describe('URL Scan Cache', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "addressScanCache": Object {}, + { + "addressScanCache": {}, "c2DomainBlocklistLastFetched": 0, "hotlistLastFetched": 0, - "phishingLists": Array [], + "phishingLists": [], "stalelistLastFetched": 0, - "tokenScanCache": Object {}, - "urlScanCache": Object {}, - "whitelist": Array [], - "whitelistPaths": Object {}, + "tokenScanCache": {}, + "urlScanCache": {}, + "whitelist": [], + "whitelistPaths": {}, } `); }); @@ -3800,10 +3840,10 @@ describe('URL Scan Cache', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "addressScanCache": Object {}, - "tokenScanCache": Object {}, - "urlScanCache": Object {}, + { + "addressScanCache": {}, + "tokenScanCache": {}, + "urlScanCache": {}, } `); }); diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 24175c5a2f7..6fba169f9a9 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -1313,9 +1313,13 @@ export class PhishingController extends BaseController< * Scan multiple tokens for malicious activity in bulk. * * @param request - The bulk scan request containing chainId and tokens. - * @param request.chainId - The chain ID in hex format (e.g., '0x1' for Ethereum). + * @param request.chainId - The chain identifier. Accepts a hex chain ID for + * EVM chains (e.g. `'0x1'` for Ethereum) or a chain name for non-EVM chains + * (e.g. `'solana'`). * @param request.tokens - Array of token addresses to scan. - * @returns A mapping of lowercase token addresses to their scan results. Tokens that fail to scan are omitted. + * @returns A mapping of token addresses to their scan results. For EVM chains, + * addresses are lowercased; for non-EVM chains, original casing is preserved. + * Tokens that fail to scan are omitted. */ bulkScanTokens = async ( request: BulkTokenScanRequest, @@ -1342,11 +1346,16 @@ export class PhishingController extends BaseController< return {}; } + // EVM addresses are case-insensitive; non-EVM addresses (e.g. Solana + // base58) are case-sensitive and must not be lowercased. + const caseSensitive = !normalizedChainId.startsWith('0x'); + // Split tokens into cached results and tokens that need to be fetched const { cachedResults, tokensToFetch } = splitCacheHits( this.#tokenScanCache, normalizedChainId, tokens, + caseSensitive, ); const results: BulkTokenScanResponse = { ...cachedResults }; @@ -1357,11 +1366,12 @@ export class PhishingController extends BaseController< chain, tokensToFetch, ); - if (apiResponse?.results) { // Process API results and update cache for (const tokenAddress of tokensToFetch) { - const normalizedAddress = tokenAddress.toLowerCase(); + const normalizedAddress = caseSensitive + ? tokenAddress + : tokenAddress.toLowerCase(); const tokenResult = apiResponse.results[normalizedAddress]; if (tokenResult?.result_type) { @@ -1375,6 +1385,7 @@ export class PhishingController extends BaseController< const cacheKey = buildCacheKey( normalizedChainId, normalizedAddress, + caseSensitive, ); this.#tokenScanCache.set(cacheKey, { result_type: tokenResult.result_type, diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 1e52e713a2e..33b89b6e06c 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -7,8 +7,13 @@ export type { PhishingDetectorConfiguration, } from './PhishingDetector'; export { PhishingDetector } from './PhishingDetector'; -export type { PhishingDetectionScanResult, AddressScanResult } from './types'; -export type { TokenScanCacheData, TokenScanResultType } from './types'; +export type { + PhishingDetectionScanResult, + AddressScanResult, + BulkTokenScanResponse, +} from './types'; +export type { TokenScanCacheData } from './types'; +export { TokenScanResultType } from './types'; export { PhishingDetectorResultType, RecommendedAction, diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts index da33614564b..169885ecfdd 100644 --- a/packages/phishing-controller/src/types.ts +++ b/packages/phishing-controller/src/types.ts @@ -214,6 +214,7 @@ export const DEFAULT_CHAIN_ID_TO_NAME = { '0x2eb': 'flow-evm', '0x8f': 'monad', '0x3e7': 'hyperevm', + solana: 'solana', } as const; export type ChainIdToNameMap = typeof DEFAULT_CHAIN_ID_TO_NAME; diff --git a/packages/phishing-controller/src/utils.test.ts b/packages/phishing-controller/src/utils.test.ts index e722714f2f0..83b14756f85 100644 --- a/packages/phishing-controller/src/utils.test.ts +++ b/packages/phishing-controller/src/utils.test.ts @@ -1,5 +1,3 @@ -import * as sinon from 'sinon'; - import { ListKeys, ListNames } from './PhishingController'; import type { PhishingListState } from './PhishingController'; import type { TokenScanResultType } from './types'; @@ -79,15 +77,26 @@ const exampleRemoveDiff = { }; describe('fetchTimeNow', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('correctly converts time from milliseconds to seconds', () => { const testTime = 1674773005000; - sinon.useFakeTimers(testTime); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: testTime, + }); const result = fetchTimeNow(); expect(result).toBe(1674773005); }); }); describe('applyDiffs', () => { + afterEach(() => { + jest.useRealTimers(); + }); + it('adds a valid addition diff to the state then sets lastUpdated to be the time of the latest diff', () => { const result = applyDiffs( exampleListState, @@ -115,7 +124,10 @@ describe('applyDiffs', () => { it('does not add an addition diff to the state if it is older than the state.lastUpdated time.', () => { const testTime = 1674773005000; - sinon.useFakeTimers(testTime); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: testTime, + }); const testExistingState = { ...exampleListState, lastUpdated: 1674773005 }; const result = applyDiffs( testExistingState, @@ -127,7 +139,10 @@ describe('applyDiffs', () => { it('does not remove a url from the state if the removal diff is older than the state.lastUpdated time.', () => { const testTime = 1674773005000; - sinon.useFakeTimers(testTime); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: testTime, + }); const testExistingState = { ...exampleListState, lastUpdated: 1674773005, @@ -149,7 +164,10 @@ describe('applyDiffs', () => { it('does not add an addition diff to the state if it does not contain the same targetlist listkey.', () => { const testTime = 1674773005000; - sinon.useFakeTimers(testTime); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: testTime, + }); const testExistingState = { ...exampleListState, lastUpdated: 1674773005 }; const result = applyDiffs( testExistingState, @@ -164,7 +182,10 @@ describe('applyDiffs', () => { it('does not remove a url from the state if it does not contain the same targetlist listkey.', () => { const testTime = 1674773005000; - sinon.useFakeTimers(testTime); + jest.useFakeTimers({ + doNotFake: ['nextTick', 'queueMicrotask'], + now: testTime, + }); const testExistingState = { ...exampleListState, lastUpdated: 1674773005, @@ -1131,6 +1152,20 @@ describe('buildCacheKey', () => { const result = buildCacheKey(chainId, address); expect(result).toBe('0x89:0xabcdef123456'); }); + + it('should preserve address casing when caseSensitive is true', () => { + const chainId = 'solana'; + const address = 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + const result = buildCacheKey(chainId, address, true); + expect(result).toBe('solana:Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'); + }); + + it('should lowercase address when caseSensitive is false (default)', () => { + const chainId = 'solana'; + const address = 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + const result = buildCacheKey(chainId, address); + expect(result).toBe('solana:gh9zwemdlj8dsckntktqpbnwlnnbjuszag9vp2kgtkjr'); + }); }); describe('resolveChainName', () => { @@ -1146,6 +1181,10 @@ describe('resolveChainName', () => { expect(resolveChainName('0XA')).toBe('optimism'); }); + it('should resolve non-EVM chain names', () => { + expect(resolveChainName('solana')).toBe('solana'); + }); + it('should return null for unknown chain IDs', () => { expect(resolveChainName('0x999')).toBeNull(); expect(resolveChainName('unknown')).toBeNull(); @@ -1246,6 +1285,38 @@ describe('splitCacheHits', () => { expect(result.cachedResults).toHaveProperty('0xtoken1'); expect(result.cachedResults['0xtoken1'].address).toBe('0xtoken1'); }); + + it('should preserve address casing when caseSensitive is true', () => { + const chainId = 'solana'; + const tokens = ['Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr']; + + mockCache.get.mockReturnValue(undefined); + + const result = splitCacheHits(mockCache, chainId, tokens, true); + + // tokensToFetch should preserve original casing + expect(result.tokensToFetch).toStrictEqual([ + 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr', + ]); + }); + + it('should return cached result with preserved casing when caseSensitive is true', () => { + const chainId = 'solana'; + const token = 'Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr'; + + mockCache.get.mockReturnValue({ + result_type: 'Benign' as TokenScanResultType, + }); + + const result = splitCacheHits(mockCache, chainId, [token], true); + + expect(result.cachedResults[token]).toStrictEqual({ + result_type: 'Benign', + chain: 'solana', + address: token, + }); + expect(result.tokensToFetch).toStrictEqual([]); + }); }); describe('getHostnameAndPathComponents', () => { diff --git a/packages/phishing-controller/src/utils.ts b/packages/phishing-controller/src/utils.ts index 819999ba3c6..3cbb8133f87 100644 --- a/packages/phishing-controller/src/utils.ts +++ b/packages/phishing-controller/src/utils.ts @@ -423,10 +423,18 @@ export const generateParentDomains = ( * * @param chainId - The chain ID. * @param address - The token address. + * @param caseSensitive - When `true`, the address is kept as-is (for chains + * like Solana where addresses are case-sensitive). When `false` (default), + * the address is lowercased (appropriate for EVM). * @returns The cache key. */ -export const buildCacheKey = (chainId: string, address: string) => { - return `${chainId.toLowerCase()}:${address.toLowerCase()}`; +export const buildCacheKey = ( + chainId: string, + address: string, + caseSensitive = false, +) => { + const normalizedAddress = caseSensitive ? address : address.toLowerCase(); + return `${chainId.toLowerCase()}:${normalizedAddress}`; }; /** @@ -450,12 +458,16 @@ export const resolveChainName = ( * @param cache.get - Method to retrieve cached data by key. * @param chainId - The chain ID. * @param tokens - Array of token addresses. + * @param caseSensitive - When `true`, token addresses are kept as-is (for + * chains like Solana where addresses are case-sensitive). When `false` + * (default), addresses are lowercased (appropriate for EVM). * @returns Object containing cached results and tokens to fetch. */ export const splitCacheHits = ( cache: { get: (key: string) => TokenScanCacheData | undefined }, chainId: string, tokens: string[], + caseSensitive = false, ): { cachedResults: Record; tokensToFetch: string[]; @@ -463,18 +475,18 @@ export const splitCacheHits = ( const cachedResults: Record = {}; const tokensToFetch: string[] = []; - for (const addr of tokens) { - const normalizedAddr = addr.toLowerCase(); - const key = buildCacheKey(chainId, normalizedAddr); + for (const address of tokens) { + const normalizedAddress = caseSensitive ? address : address.toLowerCase(); + const key = buildCacheKey(chainId, normalizedAddress, caseSensitive); const hit = cache.get(key); if (hit) { - cachedResults[normalizedAddr] = { + cachedResults[normalizedAddress] = { result_type: hit.result_type, chain: chainId, - address: normalizedAddr, + address: normalizedAddress, }; } else { - tokensToFetch.push(normalizedAddr); + tokensToFetch.push(normalizedAddress); } } diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json index f335ca91702..77f502c8349 100644 --- a/packages/polling-controller/package.json +++ b/packages/polling-controller/package.json @@ -59,12 +59,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/polling-controller/src/BlockTrackerPollingController.test.ts b/packages/polling-controller/src/BlockTrackerPollingController.test.ts index 927fb92d27e..803ef553075 100644 --- a/packages/polling-controller/src/BlockTrackerPollingController.test.ts +++ b/packages/polling-controller/src/BlockTrackerPollingController.test.ts @@ -2,7 +2,6 @@ import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; import type { NetworkClient } from '@metamask/network-controller'; import EventEmitter from 'events'; -import { useFakeTimers } from 'sinon'; import type { BlockTrackerPollingInput } from './BlockTrackerPollingController'; import { BlockTrackerPollingController } from './BlockTrackerPollingController'; @@ -43,13 +42,13 @@ class TestBlockTracker extends EventEmitter { } describe('BlockTrackerPollingController', () => { - let clock: sinon.SinonFakeTimers; let mockMessenger: Messenger; let controller: ChildBlockTrackerPollingController; let mainnetBlockTracker: TestBlockTracker; let goerliBlockTracker: TestBlockTracker; let sepoliaBlockTracker: TestBlockTracker; beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); mockMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); controller = new ChildBlockTrackerPollingController({ messenger: mockMessenger, @@ -82,10 +81,9 @@ describe('BlockTrackerPollingController', () => { throw new Error(`Unknown networkClientId: ${networkClientId}`); } }); - clock = useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('startPolling', () => { diff --git a/packages/polling-controller/src/StaticIntervalPollingController.test.ts b/packages/polling-controller/src/StaticIntervalPollingController.test.ts index 4730fb87117..e9e55b2102d 100644 --- a/packages/polling-controller/src/StaticIntervalPollingController.test.ts +++ b/packages/polling-controller/src/StaticIntervalPollingController.test.ts @@ -1,10 +1,9 @@ import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; import { createDeferredPromise } from '@metamask/utils'; -import { useFakeTimers } from 'sinon'; import { StaticIntervalPollingController } from './StaticIntervalPollingController'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; const TICK_TIME = 5; @@ -39,10 +38,10 @@ class ChildBlockTrackerPollingController extends StaticIntervalPollingController } describe('StaticIntervalPollingController', () => { - let clock: sinon.SinonFakeTimers; let mockMessenger: Messenger; let controller: ChildBlockTrackerPollingController; beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); mockMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }); controller = new ChildBlockTrackerPollingController({ messenger: mockMessenger, @@ -51,36 +50,35 @@ describe('StaticIntervalPollingController', () => { state: { foo: 'bar' }, }); controller.setIntervalLength(TICK_TIME); - clock = useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('startPolling', () => { it('should start polling if not already polling', async () => { controller.startPolling({ networkClientId: 'mainnet' }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll).toHaveBeenCalledTimes(1); controller.executePollPromises[0].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll).toHaveBeenCalledTimes(2); controller.stopAllPolling(); }); it('should call _executePoll immediately once and continue calling _executePoll on interval when called again with the same networkClientId', async () => { controller.startPolling({ networkClientId: 'mainnet' }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.startPolling({ networkClientId: 'mainnet' }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll).toHaveBeenCalledTimes(1); controller.executePollPromises[0].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); controller.executePollPromises[1].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); controller.executePollPromises[2].resolve(); expect(controller._executePoll).toHaveBeenCalledTimes(3); @@ -92,12 +90,12 @@ describe('StaticIntervalPollingController', () => { controller.startPolling({ networkClientId: 'mainnet', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.startPolling({ networkClientId: 'rinkeby', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -106,7 +104,7 @@ describe('StaticIntervalPollingController', () => { controller.executePollPromises[0].resolve(); controller.executePollPromises[1].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -117,7 +115,7 @@ describe('StaticIntervalPollingController', () => { controller.executePollPromises[2].resolve(); controller.executePollPromises[3].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -135,18 +133,18 @@ describe('StaticIntervalPollingController', () => { controller.startPolling({ networkClientId: 'mainnet', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], ]); controller.executePollPromises[0].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); controller.startPolling({ networkClientId: 'sepolia', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -154,7 +152,7 @@ describe('StaticIntervalPollingController', () => { ]); controller.executePollPromises[1].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -163,7 +161,7 @@ describe('StaticIntervalPollingController', () => { ]); controller.executePollPromises[2].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -173,7 +171,7 @@ describe('StaticIntervalPollingController', () => { ]); controller.executePollPromises[3].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -184,7 +182,7 @@ describe('StaticIntervalPollingController', () => { ]); controller.executePollPromises[4].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet' }], @@ -203,12 +201,12 @@ describe('StaticIntervalPollingController', () => { const pollingToken = controller.startPolling({ networkClientId: 'mainnet', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll).toHaveBeenCalledTimes(1); controller.executePollPromises[0].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); controller.stopPollingByPollingToken(pollingToken); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll).toHaveBeenCalledTimes(2); controller.stopAllPolling(); }); @@ -217,15 +215,15 @@ describe('StaticIntervalPollingController', () => { const pollingToken1 = controller.startPolling({ networkClientId: 'mainnet', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.startPolling({ networkClientId: 'mainnet' }); expect(controller._executePoll).toHaveBeenCalledTimes(1); controller.executePollPromises[0].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); controller.stopPollingByPollingToken(pollingToken1); controller.executePollPromises[1].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll).toHaveBeenCalledTimes(3); controller.stopAllPolling(); }); @@ -243,18 +241,18 @@ describe('StaticIntervalPollingController', () => { networkClientId: 'mainnet', address: '0x1', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.startPolling({ networkClientId: 'mainnet', address: '0x2', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); controller.startPolling({ networkClientId: 'sepolia', address: '0x2', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet', address: '0x1' }], @@ -265,7 +263,7 @@ describe('StaticIntervalPollingController', () => { controller.executePollPromises[0].resolve(); controller.executePollPromises[1].resolve(); controller.executePollPromises[2].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet', address: '0x1' }], @@ -279,7 +277,7 @@ describe('StaticIntervalPollingController', () => { controller.executePollPromises[3].resolve(); controller.executePollPromises[4].resolve(); controller.executePollPromises[5].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll.mock.calls).toMatchObject([ [{ networkClientId: 'mainnet', address: '0x1' }], @@ -297,13 +295,13 @@ describe('StaticIntervalPollingController', () => { const pollingToken = controller.startPolling({ networkClientId: 'mainnet', }); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(controller._executePoll).toHaveBeenCalledTimes(1); controller.stopPollingByPollingToken(pollingToken); controller.executePollPromises[0].resolve(); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll).toHaveBeenCalledTimes(1); - await advanceTime({ clock, duration: TICK_TIME }); + await jestAdvanceTime({ duration: TICK_TIME }); expect(controller._executePoll).toHaveBeenCalledTimes(1); controller.stopAllPolling(); }); diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index df89f29027a..30cc7f8bcb6 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -57,12 +57,12 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "lodash": "^4.17.21", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/preferences-controller/src/PreferencesController.test.ts b/packages/preferences-controller/src/PreferencesController.test.ts index e0373ec7d11..2a016ec9f81 100644 --- a/packages/preferences-controller/src/PreferencesController.test.ts +++ b/packages/preferences-controller/src/PreferencesController.test.ts @@ -598,15 +598,15 @@ describe('PreferencesController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "dismissSmartAccountSuggestionEnabled": false, "displayNftMedia": false, - "featureFlags": Object {}, + "featureFlags": {}, "isIpfsGatewayEnabled": true, "isMultiAccountBalancesEnabled": true, "privacyMode": false, "securityAlertsEnabled": false, - "showIncomingTransactions": Object { + "showIncomingTransactions": { "0x1": true, "0x13881": true, "0x38": true, @@ -634,8 +634,8 @@ describe('PreferencesController', () => { "showMultiRpcModal": false, "showTestNetworks": false, "smartAccountOptIn": true, - "smartAccountOptInForAccounts": Array [], - "tokenSortConfig": Object { + "smartAccountOptInForAccounts": [], + "tokenSortConfig": { "key": "tokenFiatAmount", "order": "dsc", "sortCallback": "stringNumeric", @@ -658,19 +658,19 @@ describe('PreferencesController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "dismissSmartAccountSuggestionEnabled": false, "displayNftMedia": false, - "featureFlags": Object {}, - "identities": Object {}, + "featureFlags": {}, + "identities": {}, "ipfsGateway": "https://ipfs.io/ipfs/", "isIpfsGatewayEnabled": true, "isMultiAccountBalancesEnabled": true, - "lostIdentities": Object {}, + "lostIdentities": {}, "privacyMode": false, "securityAlertsEnabled": false, "selectedAddress": "", - "showIncomingTransactions": Object { + "showIncomingTransactions": { "0x1": true, "0x13881": true, "0x38": true, @@ -698,10 +698,10 @@ describe('PreferencesController', () => { "showMultiRpcModal": false, "showTestNetworks": false, "smartAccountOptIn": true, - "smartAccountOptInForAccounts": Array [], + "smartAccountOptInForAccounts": [], "smartTransactionsOptInStatus": true, - "tokenNetworkFilter": Object {}, - "tokenSortConfig": Object { + "tokenNetworkFilter": {}, + "tokenSortConfig": { "key": "tokenFiatAmount", "order": "dsc", "sortCallback": "stringNumeric", @@ -724,19 +724,19 @@ describe('PreferencesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "dismissSmartAccountSuggestionEnabled": false, "displayNftMedia": false, - "featureFlags": Object {}, - "identities": Object {}, + "featureFlags": {}, + "identities": {}, "ipfsGateway": "https://ipfs.io/ipfs/", "isIpfsGatewayEnabled": true, "isMultiAccountBalancesEnabled": true, - "lostIdentities": Object {}, + "lostIdentities": {}, "privacyMode": false, "securityAlertsEnabled": false, "selectedAddress": "", - "showIncomingTransactions": Object { + "showIncomingTransactions": { "0x1": true, "0x13881": true, "0x38": true, @@ -764,10 +764,10 @@ describe('PreferencesController', () => { "showMultiRpcModal": false, "showTestNetworks": false, "smartAccountOptIn": true, - "smartAccountOptInForAccounts": Array [], + "smartAccountOptInForAccounts": [], "smartTransactionsOptInStatus": true, - "tokenNetworkFilter": Object {}, - "tokenSortConfig": Object { + "tokenNetworkFilter": {}, + "tokenSortConfig": { "key": "tokenFiatAmount", "order": "dsc", "sortCallback": "stringNumeric", @@ -790,18 +790,18 @@ describe('PreferencesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "dismissSmartAccountSuggestionEnabled": false, "displayNftMedia": false, - "featureFlags": Object {}, - "identities": Object {}, + "featureFlags": {}, + "identities": {}, "ipfsGateway": "https://ipfs.io/ipfs/", "isIpfsGatewayEnabled": true, "isMultiAccountBalancesEnabled": true, "privacyMode": false, "securityAlertsEnabled": false, "selectedAddress": "", - "showIncomingTransactions": Object { + "showIncomingTransactions": { "0x1": true, "0x13881": true, "0x38": true, @@ -829,10 +829,10 @@ describe('PreferencesController', () => { "showMultiRpcModal": false, "showTestNetworks": false, "smartAccountOptIn": true, - "smartAccountOptInForAccounts": Array [], + "smartAccountOptInForAccounts": [], "smartTransactionsOptInStatus": true, - "tokenNetworkFilter": Object {}, - "tokenSortConfig": Object { + "tokenNetworkFilter": {}, + "tokenSortConfig": { "key": "tokenFiatAmount", "order": "dsc", "sortCallback": "stringNumeric", diff --git a/packages/profile-metrics-controller/CHANGELOG.md b/packages/profile-metrics-controller/CHANGELOG.md index 148c01e43cf..bd4f6945b26 100644 --- a/packages/profile-metrics-controller/CHANGELOG.md +++ b/packages/profile-metrics-controller/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.0.1] + ### Changed +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/profile-sync-controller` from `^27.0.0` to `^27.1.0` ([#7849](https://github.com/MetaMask/core/pull/7849)) -- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.16.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.17.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713)) ## [3.0.0] @@ -54,7 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#7194](https://github.com/MetaMask/core/pull/7194), [#7196](https://github.com/MetaMask/core/pull/7196), [#7263](https://github.com/MetaMask/core/pull/7263)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@3.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@3.0.1...HEAD +[3.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@3.0.0...@metamask/profile-metrics-controller@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@2.0.0...@metamask/profile-metrics-controller@3.0.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@1.1.0...@metamask/profile-metrics-controller@2.0.0 [1.1.0]: https://github.com/MetaMask/core/compare/@metamask/profile-metrics-controller@1.0.0...@metamask/profile-metrics-controller@1.1.0 diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 73fc46fc730..19911979d38 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/profile-metrics-controller", - "version": "3.0.0", + "version": "3.0.1", "description": "Sample package to illustrate best practices for controllers", "keywords": [ "MetaMask", @@ -48,14 +48,14 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/keyring-controller": "^25.1.0", "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^16.0.2", "@metamask/profile-sync-controller": "^27.1.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, @@ -63,13 +63,12 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-internal-api": "^10.0.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts index 350cfe5a6fc..b6890783e76 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsController.test.ts @@ -567,11 +567,11 @@ describe('ProfileMetricsController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "initialDelayEndTimestamp": 10, "initialEnqueueCompleted": false, } - `); + `); }, ); }); @@ -587,12 +587,12 @@ describe('ProfileMetricsController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "initialDelayEndTimestamp": 10, "initialEnqueueCompleted": false, - "syncQueue": Object {}, + "syncQueue": {}, } - `); + `); }, ); }); @@ -608,12 +608,12 @@ describe('ProfileMetricsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "initialDelayEndTimestamp": 10, "initialEnqueueCompleted": false, - "syncQueue": Object {}, + "syncQueue": {}, } - `); + `); }, ); }); @@ -626,7 +626,7 @@ describe('ProfileMetricsController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts index 2b50fe91bba..9bc4f621d9e 100644 --- a/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts +++ b/packages/profile-metrics-controller/src/ProfileMetricsService.test.ts @@ -1,3 +1,4 @@ +import { HttpError } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -6,8 +7,6 @@ import type { } from '@metamask/messenger'; import { SDK } from '@metamask/profile-sync-controller'; import nock from 'nock'; -import { useFakeTimers } from 'sinon'; -import type { SinonFakeTimers } from 'sinon'; import { ProfileMetricsService } from '.'; import type { @@ -15,7 +14,6 @@ import type { ProfileMetricsServiceMessenger, } from '.'; import { getAuthUrl } from './ProfileMetricsService'; -import { HttpError } from '../../controller-utils/src/util'; const defaultBaseEndpoint = getAuthUrl(SDK.Env.DEV); @@ -37,14 +35,12 @@ function createMockRequest( } describe('ProfileMetricsService', () => { - let clock: SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('constructor', () => { @@ -104,7 +100,7 @@ describe('ProfileMetricsService', () => { nock(defaultBaseEndpoint) .put('/profile/accounts') .reply(200, () => { - clock.tick(6000); + jest.advanceTimersByTime(6000); return { data: { success: true, @@ -127,7 +123,7 @@ describe('ProfileMetricsService', () => { nock(defaultBaseEndpoint) .put('/profile/accounts') .reply(200, () => { - clock.tick(1000); + jest.advanceTimersByTime(1000); return { data: { success: true, @@ -153,7 +149,9 @@ describe('ProfileMetricsService', () => { it('attempts a request that responds with non-200 up to 4 times, throwing if it never succeeds', async () => { nock(defaultBaseEndpoint).put('/profile/accounts').times(4).reply(500); const { service, rootMessenger } = getService(); - service.onRetry(clock.next); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); await expect( rootMessenger.call( @@ -168,7 +166,9 @@ describe('ProfileMetricsService', () => { it('calls onDegraded listeners when the maximum number of retries is exceeded', async () => { nock(defaultBaseEndpoint).put('/profile/accounts').times(4).reply(500); const { service, rootMessenger } = getService(); - service.onRetry(clock.next); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); const onDegradedListener = jest.fn(); service.onDegraded(onDegradedListener); @@ -186,7 +186,9 @@ describe('ProfileMetricsService', () => { it('intercepts requests and throws a circuit break error after the 4th failed attempt, running onBreak listeners', async () => { nock(defaultBaseEndpoint).put('/profile/accounts').times(12).reply(500); const { service, rootMessenger } = getService(); - service.onRetry(clock.next); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); const onBreakListener = jest.fn(); service.onBreak(onBreakListener); @@ -252,7 +254,9 @@ describe('ProfileMetricsService', () => { policyOptions: { circuitBreakDuration }, }, }); - service.onRetry(clock.next); + service.onRetry(({ delay }: { delay: number }) => { + jest.advanceTimersByTime(delay); + }); await expect( rootMessenger.call( @@ -286,7 +290,7 @@ describe('ProfileMetricsService', () => { ).rejects.toThrow( 'Execution prevented because the circuit breaker is open', ); - await clock.tickAsync(circuitBreakDuration); + jest.advanceTimersByTime(circuitBreakDuration); const submitMetricsResponse = await service.submitMetrics(createMockRequest()); expect(submitMetricsResponse).toBeUndefined(); diff --git a/packages/profile-sync-controller/jest.environment.js b/packages/profile-sync-controller/jest.environment.js index 42e6f334993..2e6fb2965b7 100644 --- a/packages/profile-sync-controller/jest.environment.js +++ b/packages/profile-sync-controller/jest.environment.js @@ -1,6 +1,6 @@ /* eslint-disable n/prefer-global/text-encoder */ /* eslint-disable n/prefer-global/text-decoder */ -const JSDOMEnvironment = require('jest-environment-jsdom'); +const { TestEnvironment } = require('jest-environment-jsdom'); /** * ProfileSync SDK & Controllers depends on @noble/hashes, which as of 1.3.2 relies on the @@ -8,7 +8,7 @@ const JSDOMEnvironment = require('jest-environment-jsdom'); * * There are also EIP6963 utils that utilize window */ -class CustomTestEnvironment extends JSDOMEnvironment { +class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 536ac69a75a..c77b258cfcb 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -123,14 +123,14 @@ "@metamask/keyring-internal-api": "^10.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "ethers": "^6.12.0", - "jest": "^27.5.1", - "jest-environment-jsdom": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "webextension-polyfill": "^0.12.0" 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 d92b0701e8f..09fed6861d3 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.test.ts @@ -564,7 +564,7 @@ describe('metadata', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "isSignedIn": true, } `); @@ -586,27 +586,27 @@ describe('metadata', () => { ); expect(derivedState).toMatchInlineSnapshot(` - Object { + { "isSignedIn": true, - "srpSessionData": Object { - "MOCK_ENTROPY_SOURCE_ID": Object { - "profile": Object { + "srpSessionData": { + "MOCK_ENTROPY_SOURCE_ID": { + "profile": { "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", }, - "token": Object { + "token": { "expiresIn": 1000, "obtainedAt": 0, }, }, - "MOCK_ENTROPY_SOURCE_ID2": Object { - "profile": Object { + "MOCK_ENTROPY_SOURCE_ID2": { + "profile": { "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", }, - "token": Object { + "token": { "expiresIn": 1000, "obtainedAt": 0, }, @@ -629,7 +629,7 @@ describe('metadata', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "isSignedIn": false, } `); @@ -647,28 +647,28 @@ describe('metadata', () => { expect( deriveStateFromMetadata(controller.state, controller.metadata, 'persist'), ).toMatchInlineSnapshot(` - Object { + { "isSignedIn": true, - "srpSessionData": Object { - "MOCK_ENTROPY_SOURCE_ID": Object { - "profile": Object { + "srpSessionData": { + "MOCK_ENTROPY_SOURCE_ID": { + "profile": { "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", }, - "token": Object { + "token": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", "expiresIn": 1000, "obtainedAt": 0, }, }, - "MOCK_ENTROPY_SOURCE_ID2": Object { - "profile": Object { + "MOCK_ENTROPY_SOURCE_ID2": { + "profile": { "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", }, - "token": Object { + "token": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", "expiresIn": 1000, "obtainedAt": 0, @@ -694,28 +694,28 @@ describe('metadata', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "isSignedIn": true, - "srpSessionData": Object { - "MOCK_ENTROPY_SOURCE_ID": Object { - "profile": Object { + "srpSessionData": { + "MOCK_ENTROPY_SOURCE_ID": { + "profile": { "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", }, - "token": Object { + "token": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", "expiresIn": 1000, "obtainedAt": 0, }, }, - "MOCK_ENTROPY_SOURCE_ID2": Object { - "profile": Object { + "MOCK_ENTROPY_SOURCE_ID2": { + "profile": { "identifierId": "da9a9fc7b09edde9cc23cec9b7e11a71fb0ab4d2ddd8af8af905306f3e1456fb", "metaMetricsId": "561ec651-a844-4b36-a451-04d6eac35740", "profileId": "f88227bd-b615-41a3-b0be-467dd781a4ad", }, - "token": Object { + "token": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", "expiresIn": 1000, "obtainedAt": 0, 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 b2e6a9e676a..01ad34ea1af 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 @@ -769,7 +769,7 @@ describe('metadata', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, @@ -789,7 +789,7 @@ describe('metadata', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, @@ -805,7 +805,7 @@ describe('metadata', () => { expect( deriveStateFromMetadata(controller.state, controller.metadata, 'persist'), ).toMatchInlineSnapshot(` - Object { + { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isContactSyncingEnabled": true, @@ -825,7 +825,7 @@ describe('metadata', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { + { "isAccountSyncingEnabled": true, "isBackupAndSyncEnabled": true, "isBackupAndSyncUpdateLoading": false, diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 8a6c96a8f82..d0decd111b7 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.1.0] + +### Added + +- Add `widgetUrl` resource state that automatically fetches and stores the buy widget URL whenever the selected quote changes ([#7920](https://github.com/MetaMask/core/pull/7920)) +- Add `TransakService` for native Transak deposit flow with OTP auth, KYC, quoting, order lifecycle, and payment widget URL generation ([#7922](https://github.com/MetaMask/core/pull/7922)) +- Add `nativeProviders.transak` state slice and controller convenience methods for driving the Transak native deposit flow ([#7922](https://github.com/MetaMask/core/pull/7922)) + +### Changed + +- Refactor: Consolidate reset logic with a shared resetResource helper and fix abort handling for dependent resources ([#7818](https://github.com/MetaMask/core/pull/7818)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Quote filter param renamed from `provider` to `providers` array in `getQuotes()` and `RampsService.getQuotes()` ([#7892](https://github.com/MetaMask/core/pull/7892)) +- **BREAKING:** Make `getWidgetUrl()` async to fetch the actual provider widget URL from the `buyURL` endpoint ([#7881](https://github.com/MetaMask/core/pull/7881)) + ## [7.1.0] ### Fixed @@ -134,7 +153,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `OnRampService` for interacting with the OnRamp API - Add geolocation detection via IP address lookup -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@7.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@8.1.0...HEAD +[8.1.0]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@8.0.0...@metamask/ramps-controller@8.1.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@7.1.0...@metamask/ramps-controller@8.0.0 [7.1.0]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@7.0.0...@metamask/ramps-controller@7.1.0 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@6.0.0...@metamask/ramps-controller@7.0.0 [6.0.0]: https://github.com/MetaMask/core/compare/@metamask/ramps-controller@5.1.0...@metamask/ramps-controller@6.0.0 diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index aaff5e3a274..da9f373834e 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/ramps-controller", - "version": "7.1.0", + "version": "8.1.0", "description": "A controller for managing cryptocurrency on/off ramps functionality", "keywords": [ "MetaMask", @@ -56,15 +56,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", - "@types/sinon": "^9.0.10", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "isomorphic-fetch": "^3.0.0", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 0b0e3b9a2f8..050479c916f 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -10,11 +10,13 @@ import * as path from 'path'; import type { RampsControllerMessenger, + RampsControllerState, ResourceState, UserRegion, } from './RampsController'; import { RampsController, + getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, } from './RampsController'; import type { @@ -35,8 +37,25 @@ import type { RampsServiceGetProvidersAction, RampsServiceGetPaymentMethodsAction, RampsServiceGetQuotesAction, + RampsServiceGetBuyWidgetUrlAction, } from './RampsService-method-action-types'; import { RequestStatus } from './RequestCache'; +import type { + TransakAccessToken, + TransakUserDetails, + TransakBuyQuote, + TransakKycRequirement, + TransakAdditionalRequirementsResponse, + TransakDepositOrder, + TransakUserLimits, + TransakOttResponse, + TransakQuoteTranslation, + TransakTranslationRequest, + TransakIdProofStatus, + TransakOrder, + TransakOrderPaymentMethod, + PatchUserRequestBody, +} from './TransakService'; describe('RampsController', () => { describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { @@ -45,7 +64,7 @@ describe('RampsController', () => { const controllerPath = path.join(__dirname, 'RampsController.ts'); const source = await fs.promises.readFile(controllerPath, 'utf-8'); const callPattern = - /messenger\.call\s*\(\s*['"](RampsService:[^'"]+)['"]/gu; + /messenger\.call\s*\(\s*['"]((RampsService|TransakService):[^'"]+)['"]/gu; const calledActions = new Set(); let match: RegExpExecArray | null; while ((match = callPattern.exec(source)) !== null) { @@ -65,39 +84,68 @@ describe('RampsController', () => { it('uses default state when no state is provided', async () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "countries": Object { - "data": Array [], + { + "countries": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "paymentMethods": Object { - "data": Array [], + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + }, + }, + "paymentMethods": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "providers": Object { - "data": Array [], + "providers": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "quotes": Object { + "quotes": { "data": null, "error": null, "isLoading": false, "selected": null, }, - "requests": Object {}, - "tokens": Object { + "requests": {}, + "tokens": { "data": null, "error": null, "isLoading": false, "selected": null, }, "userRegion": null, + "widgetUrl": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -122,39 +170,68 @@ describe('RampsController', () => { it('fills in missing initial state with defaults', async () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "countries": Object { - "data": Array [], + { + "countries": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "paymentMethods": Object { - "data": Array [], + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + }, + }, + "paymentMethods": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "providers": Object { - "data": Array [], + "providers": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "quotes": Object { + "quotes": { "data": null, "error": null, "isLoading": false, "selected": null, }, - "requests": Object {}, - "tokens": Object { + "requests": {}, + "tokens": { "data": null, "error": null, "isLoading": false, "selected": null, }, "userRegion": null, + "widgetUrl": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -466,10 +543,65 @@ describe('RampsController', () => { it('throws error when region is not provided and userRegion is not set', async () => { await withController(async ({ controller }) => { await expect(controller.getProviders()).rejects.toThrow( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', + 'Region is required. Cannot proceed without valid region information.', ); }); }); + + it('returns providers for region when state has providers (fetches and returns result)', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState(mockProviders, null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let serviceCalled = false; + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => { + serviceCalled = true; + return { providers: mockProviders }; + }, + ); + + const result = await controller.getProviders('us-ca'); + + expect(serviceCalled).toBe(true); + expect(result.providers).toStrictEqual(mockProviders); + }, + ); + }); + + it('calls service when getProviders is called with filter options even if state has providers', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState(mockProviders, null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let serviceCalled = false; + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => { + serviceCalled = true; + return { providers: mockProviders }; + }, + ); + + await controller.getProviders('us-ca', { provider: 'moonpay' }); + + expect(serviceCalled).toBe(true); + }, + ); + }); }); describe('metadata', () => { @@ -482,39 +614,68 @@ describe('RampsController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "countries": Object { - "data": Array [], + { + "countries": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "paymentMethods": Object { - "data": Array [], + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + }, + }, + "paymentMethods": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "providers": Object { - "data": Array [], + "providers": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "quotes": Object { + "quotes": { "data": null, "error": null, "isLoading": false, "selected": null, }, - "requests": Object {}, - "tokens": Object { + "requests": {}, + "tokens": { "data": null, "error": null, "isLoading": false, "selected": null, }, "userRegion": null, + "widgetUrl": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -529,26 +690,26 @@ describe('RampsController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "countries": Object { - "data": Array [], + { + "countries": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "paymentMethods": Object { - "data": Array [], + "paymentMethods": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "providers": Object { - "data": Array [], + "providers": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "tokens": Object { + "tokens": { "data": null, "error": null, "isLoading": false, @@ -569,20 +730,20 @@ describe('RampsController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "countries": Object { - "data": Array [], + { + "countries": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "providers": Object { - "data": Array [], + "providers": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "tokens": Object { + "tokens": { "data": null, "error": null, "isLoading": false, @@ -603,39 +764,68 @@ describe('RampsController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "countries": Object { - "data": Array [], + { + "countries": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "paymentMethods": Object { - "data": Array [], + "nativeProviders": { + "transak": { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + }, + }, + "paymentMethods": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "providers": Object { - "data": Array [], + "providers": { + "data": [], "error": null, "isLoading": false, "selected": null, }, - "quotes": Object { + "quotes": { "data": null, "error": null, "isLoading": false, "selected": null, }, - "requests": Object {}, - "tokens": Object { + "requests": {}, + "tokens": { "data": null, "error": null, "isLoading": false, "selected": null, }, "userRegion": null, + "widgetUrl": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, } `); }); @@ -1107,34 +1297,34 @@ describe('RampsController', () => { const countries = await controller.getCountries(); expect(countries).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "currency": "USD", "flag": "🇺🇸", "isoCode": "US", "name": "United States of America", - "phone": Object { + "phone": { "placeholder": "(555) 123-4567", "prefix": "+1", "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": Object { + "supported": { "buy": true, "sell": true, }, }, - Object { + { "currency": "EUR", "flag": "🇦🇹", "isoCode": "AT", "name": "Austria", - "phone": Object { + "phone": { "placeholder": "660 1234567", "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": Object { + "supported": { "buy": true, "sell": false, }, @@ -1144,6 +1334,44 @@ describe('RampsController', () => { expect(controller.state.countries.data).toStrictEqual(mockCountries); }); }); + + it('stores empty array when getCountries returns non-array (defensive)', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => 'not an array' as unknown as Country[], + ); + + const countries = await controller.getCountries(); + + expect(countries).toBe('not an array'); + expect(controller.state.countries.data).toStrictEqual([]); + }); + }); + + it('throws when updating resource field and resource is null', async () => { + const stateWithNullCountries = { + ...getDefaultRampsControllerState(), + countries: null, + } as unknown as RampsControllerState; + + await withController( + { + options: { + state: stateWithNullCountries, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getCountries', + async () => mockCountries, + ); + await expect(controller.getCountries()).rejects.toThrow( + /Cannot set propert(y|ies) of null/u, + ); + }, + ); + }); }); describe('init', () => { @@ -1365,10 +1593,54 @@ describe('RampsController', () => { it('throws error when userRegion is not set', async () => { await withController(async ({ controller }) => { expect(() => controller.hydrateState()).toThrow( - 'Region code is required. Cannot hydrate state without valid region information.', + 'Region is required. Cannot proceed without valid region information.', ); }); }); + + it('calls getTokens and getProviders when hydrating even if state has data', async () => { + const existingProviders: Provider[] = [ + { + id: '/providers/test', + name: 'Test Provider', + environmentType: 'STAGING', + description: 'Test', + hqAddress: '123 Test St', + links: [], + logos: { light: '', dark: '', height: 24, width: 77 }, + }, + ]; + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + providers: createResourceState(existingProviders, null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let providersCalled = false; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => ({ topTokens: [], allTokens: [] }), + ); + rootMessenger.registerActionHandler( + 'RampsService:getProviders', + async () => { + providersCalled = true; + return { providers: [] }; + }, + ); + + controller.hydrateState(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(providersCalled).toBe(true); + }, + ); + }); }); describe('setUserRegion', () => { @@ -2069,6 +2341,8 @@ describe('RampsController', () => { expect(controller.state.providers.selected).toBeNull(); expect(controller.state.paymentMethods.data).toStrictEqual([]); expect(controller.state.paymentMethods.selected).toBeNull(); + expect(controller.state.paymentMethods.isLoading).toBe(false); + expect(controller.state.paymentMethods.error).toBeNull(); }, ); }); @@ -2086,7 +2360,7 @@ describe('RampsController', () => { expect(() => { controller.setSelectedProvider(mockProvider.id); }).toThrow( - 'Region is required. Cannot set selected provider without valid region information.', + 'Region is required. Cannot proceed without valid region information.', ); }, ); @@ -2263,6 +2537,8 @@ describe('RampsController', () => { expect(controller.state.tokens.selected).toBeNull(); expect(controller.state.paymentMethods.data).toStrictEqual([]); expect(controller.state.paymentMethods.selected).toBeNull(); + expect(controller.state.paymentMethods.isLoading).toBe(false); + expect(controller.state.paymentMethods.error).toBeNull(); }, ); }); @@ -2278,7 +2554,7 @@ describe('RampsController', () => { }, async ({ controller }) => { expect(() => controller.setSelectedToken(mockToken.assetId)).toThrow( - 'Region is required. Cannot set selected token without valid region information.', + 'Region is required. Cannot proceed without valid region information.', ); }, ); @@ -2454,9 +2730,9 @@ describe('RampsController', () => { const tokens = await controller.getTokens('us-ca', 'buy'); expect(tokens).toMatchInlineSnapshot(` - Object { - "allTokens": Array [ - Object { + { + "allTokens": [ + { "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "chainId": "eip155:1", "decimals": 6, @@ -2465,7 +2741,7 @@ describe('RampsController', () => { "symbol": "USDC", "tokenSupported": true, }, - Object { + { "assetId": "eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7", "chainId": "eip155:1", "decimals": 6, @@ -2475,8 +2751,8 @@ describe('RampsController', () => { "tokenSupported": true, }, ], - "topTokens": Array [ - Object { + "topTokens": [ + { "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "chainId": "eip155:1", "decimals": 6, @@ -2632,11 +2908,66 @@ describe('RampsController', () => { it('throws error when region is not provided and userRegion is not set', async () => { await withController(async ({ controller }) => { await expect(controller.getTokens(undefined, 'buy')).rejects.toThrow( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', + 'Region is required. Cannot proceed without valid region information.', ); }); }); + it('returns tokens for region when state has tokens (fetches and returns result)', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + tokens: createResourceState(mockTokens, null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let serviceCalled = false; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => { + serviceCalled = true; + return mockTokens; + }, + ); + + const result = await controller.getTokens('us-ca', 'buy'); + + expect(serviceCalled).toBe(true); + expect(result).toStrictEqual(mockTokens); + }, + ); + }); + + it('calls service when getTokens is called with provider filter even if state has tokens', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us-ca'), + tokens: createResourceState(mockTokens, null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let serviceCalled = false; + rootMessenger.registerActionHandler( + 'RampsService:getTokens', + async () => { + serviceCalled = true; + return mockTokens; + }, + ); + + await controller.getTokens('us-ca', 'buy', { provider: 'moonpay' }); + + expect(serviceCalled).toBe(true); + }, + ); + }); + it('prefers provided region over userRegion in state', async () => { await withController( { @@ -3271,7 +3602,7 @@ describe('RampsController', () => { provider: '/providers/stripe', }), ).rejects.toThrow( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', + 'Region is required. Cannot proceed without valid region information.', ); }); }); @@ -3402,10 +3733,10 @@ describe('RampsController', () => { ); controller.setSelectedToken(tokenB.assetId); - await new Promise((resolve) => setTimeout(resolve, 10)); resolveTokenARequest({ payments: paymentMethodsForTokenA }); await tokenAPaymentMethodsPromise; + await new Promise((resolve) => setTimeout(resolve, 10)); expect(controller.state.tokens.selected).toStrictEqual(tokenB); expect(controller.state.paymentMethods.data).toStrictEqual( @@ -3510,10 +3841,10 @@ describe('RampsController', () => { ); controller.setSelectedProvider(providerB.id); - await new Promise((resolve) => setTimeout(resolve, 10)); resolveProviderARequest({ payments: paymentMethodsForProviderA }); await providerAPaymentMethodsPromise; + await new Promise((resolve) => setTimeout(resolve, 10)); expect(controller.state.providers.selected).toStrictEqual(providerB); expect(controller.state.paymentMethods.data).toStrictEqual( @@ -3686,7 +4017,6 @@ describe('RampsController', () => { amountOut: '0.05', paymentMethod: '/payments/debit-credit-card', amountOutInFiat: 98, - widgetUrl: 'https://buy.moonpay.com/widget?txId=123', }, metadata: { reliability: 95, @@ -3751,12 +4081,24 @@ describe('RampsController', () => { ); }); - it('uses userRegion from state when not provided', async () => { + it('uses selected token assetId from state when assetId option is not provided', async () => { await withController( { options: { state: { userRegion: createMockUserRegion('us'), + tokens: createResourceState( + { topTokens: [], allTokens: [] }, + { + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + iconUrl: 'https://example.com/eth.png', + tokenSupported: true, + }, + ), paymentMethods: createResourceState( [ { @@ -3776,51 +4118,91 @@ describe('RampsController', () => { rootMessenger.registerActionHandler( 'RampsService:getQuotes', async (params) => { - expect(params.region).toBe('us'); - expect(params.fiat).toBe('usd'); + expect(params.assetId).toBe('eip155:1/slip44:60'); return mockQuotesResponse; }, ); - await controller.getQuotes({ - assetId: 'eip155:1/slip44:60', + const result = await controller.getQuotes({ amount: 100, walletAddress: '0x1234567890abcdef1234567890abcdef12345678', }); + + expect(result.success).toHaveLength(1); }, ); }); - it('throws when region is not provided and not in state', async () => { - await withController(async ({ controller }) => { - await expect( - controller.getQuotes({ - assetId: 'eip155:1/slip44:60', - amount: 100, - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - paymentMethods: ['/payments/debit-credit-card'], - }), - ).rejects.toThrow('Region is required'); - }); - }); - - it('throws when fiat is not provided and not in state', async () => { + it('uses userRegion from state when not provided', async () => { await withController( { options: { state: { - userRegion: { - country: { - isoCode: 'US', - name: 'United States', - flag: '🇺🇸', - currency: '', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: { buy: true, sell: true }, - }, - state: null, - regionCode: 'us', - }, + userRegion: createMockUserRegion('us'), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params) => { + expect(params.region).toBe('us'); + expect(params.fiat).toBe('usd'); + return mockQuotesResponse; + }, + ); + + await controller.getQuotes({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }); + }, + ); + }); + + it('throws when region is not provided and not in state', async () => { + await withController(async ({ controller }) => { + await expect( + controller.getQuotes({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + }), + ).rejects.toThrow('Region is required'); + }); + }); + + it('throws when fiat is not provided and not in state', async () => { + await withController( + { + options: { + state: { + userRegion: { + country: { + isoCode: 'US', + name: 'United States', + flag: '🇺🇸', + currency: '', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: { buy: true, sell: true }, + }, + state: null, + regionCode: 'us', + }, }, }, }, @@ -3949,6 +4331,38 @@ describe('RampsController', () => { ); }); + it('throws when assetId is not provided and no token is selected', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), + }, + }, + }, + async ({ controller }) => { + await expect( + controller.getQuotes({ + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + }), + ).rejects.toThrow('assetId is required'); + }, + ); + }); + it('throws when walletAddress is empty', async () => { await withController( { @@ -4101,6 +4515,130 @@ describe('RampsController', () => { ); }); + it('passes providers parameter to getQuotes', async () => { + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let capturedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params) => { + capturedProviders = params.providers; + return mockQuotesResponse; + }, + ); + + await controller.getQuotes({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + providers: ['/providers/moonpay', '/providers/transak'], + }); + + expect(capturedProviders).toStrictEqual([ + '/providers/moonpay', + '/providers/transak', + ]); + }, + ); + }); + + it('uses state providers when providers option is not provided', async () => { + const stateProviders = [ + { + id: '/providers/moonpay', + name: 'MoonPay', + environmentType: 'PRODUCTION' as const, + description: 'MoonPay', + hqAddress: '', + links: [], + logos: { + light: '', + dark: '', + height: 24, + width: 77, + }, + }, + { + id: '/providers/transak', + name: 'Transak', + environmentType: 'PRODUCTION' as const, + description: 'Transak', + hqAddress: '', + links: [], + logos: { + light: '', + dark: '', + height: 24, + width: 77, + }, + }, + ]; + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + paymentMethods: createResourceState( + [ + { + id: '/payments/debit-credit-card', + paymentType: 'debit-credit-card', + name: 'Debit or Credit', + score: 90, + icon: 'card', + }, + ], + null, + ), + providers: createResourceState(stateProviders, null), + }, + }, + }, + async ({ controller, rootMessenger }) => { + let capturedProviders: string[] | undefined; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async (params) => { + capturedProviders = params.providers; + return mockQuotesResponse; + }, + ); + + await controller.getQuotes({ + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + }); + + expect(capturedProviders).toStrictEqual([ + '/providers/moonpay', + '/providers/transak', + ]); + }, + ); + }); + it('does not update state when region changes during request', async () => { await withController( { @@ -4161,16 +4699,14 @@ describe('RampsController', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', }); - // Change region while request is in flight + // Change region while request is in flight (aborts dependent requests) await controller.setUserRegion('fr'); - // Resolve the quotes request if (regionChangeResolve) { regionChangeResolve(); } - await quotesPromise; + await expect(quotesPromise).rejects.toThrow('Request was aborted'); - // Quotes should not be updated because region changed expect(controller.state.quotes.data).toBeNull(); }, ); @@ -4194,7 +4730,7 @@ describe('RampsController', () => { amount: 100, }), ).toThrow( - 'Region is required. Cannot start quote polling without valid region information.', + 'Region is required. Cannot proceed without valid region information.', ); }); }); @@ -4255,7 +4791,7 @@ describe('RampsController', () => { ); }); - it('returns early without throwing when payment method is not selected', async () => { + it('returns early without starting polling when payment method is not selected', async () => { await withController( { options: { @@ -4297,6 +4833,8 @@ describe('RampsController', () => { amount: 100, }), ).not.toThrow(); + + expect(controller.state.quotes.data).toBeNull(); }, ); }); @@ -5408,46 +5946,269 @@ describe('RampsController', () => { }, ); }); - }); - describe('polling restart on dependency changes', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); + it('fetches widget URL when selecting a quote with buyURL', async () => { + await withController(async ({ controller, rootMessenger }) => { + const buyWidgetResponse = { + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER' as const, + orderId: null, + }; - afterEach(() => { - jest.useRealTimers(); + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => buyWidgetResponse, + ); + + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; + + controller.setSelectedQuote(quote); + + expect(controller.state.widgetUrl.isLoading).toBe(true); + expect(controller.state.widgetUrl.data).toBeNull(); + + await flushPromises(); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toStrictEqual( + buyWidgetResponse, + ); + expect(controller.state.widgetUrl.error).toBeNull(); + }); }); - it('restarts polling when payment method changes', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, + it('resets widget URL when selecting a quote without buyURL', async () => { + await withController(({ controller }) => { + const quote: Quote = { + provider: '/providers/moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', }, - ], - sorted: [], - error: [], - customActions: [], - }; + }; - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', + controller.setSelectedQuote(quote); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toBeNull(); + expect(controller.state.widgetUrl.error).toBeNull(); + }); + }); + + it('resets widget URL when clearing the selected quote', async () => { + await withController(({ controller }) => { + controller.setSelectedQuote(null); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toBeNull(); + expect(controller.state.widgetUrl.error).toBeNull(); + }); + }); + + it('sets widget URL error state when service call fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => { + throw new Error('Network error'); + }, + ); + + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; + + controller.setSelectedQuote(quote); + + expect(controller.state.widgetUrl.isLoading).toBe(true); + + await flushPromises(); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toBeNull(); + expect(controller.state.widgetUrl.error).toBe('Network error'); + }); + }); + + it('sets fallback widget URL error when service throws a non-Error', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw 'unexpected failure'; + }, + ); + + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; + + controller.setSelectedQuote(quote); + + await flushPromises(); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toBeNull(); + expect(controller.state.widgetUrl.error).toBe( + 'Failed to fetch widget URL', + ); + }); + }); + + it('does not reset widget URL to loading when data already exists', async () => { + await withController(async ({ controller, rootMessenger }) => { + const buyWidgetResponse = { + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER' as const, + orderId: null, + }; + + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => buyWidgetResponse, + ); + + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; + + controller.setSelectedQuote(quote); + await flushPromises(); + + expect(controller.state.widgetUrl.data).toStrictEqual( + buyWidgetResponse, + ); + + controller.setSelectedQuote(quote); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toStrictEqual( + buyWidgetResponse, + ); + }); + }); + + it('preserves existing widget URL data when a revalidation request fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + const buyWidgetResponse = { + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER' as const, + orderId: null, + }; + + let shouldFail = false; + + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => { + if (shouldFail) { + throw new Error('Network error'); + } + return buyWidgetResponse; + }, + ); + + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; + + controller.setSelectedQuote(quote); + await flushPromises(); + + expect(controller.state.widgetUrl.data).toStrictEqual( + buyWidgetResponse, + ); + + shouldFail = true; + controller.setSelectedQuote(quote); + await flushPromises(); + + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toStrictEqual( + buyWidgetResponse, + ); + expect(controller.state.widgetUrl.error).toBe('Network error'); + }); + }); + }); + + describe('polling restart on dependency changes', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('restarts polling when payment method changes', async () => { + const mockQuotesResponse: QuotesResponse = { + success: [ + { + provider: '/providers/moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + }, + }, + ], + sorted: [], + error: [], + customActions: [], + }; + + await withController( + { + options: { + state: { + userRegion: createMockUserRegion('us'), + tokens: createResourceState( + { topTokens: [], allTokens: [] }, + { + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + name: 'Ethereum', + symbol: 'ETH', decimals: 18, iconUrl: 'https://example.com/eth.png', tokenSupported: true, @@ -5639,9 +6400,7 @@ describe('RampsController', () => { // Advance time - polling should not fire jest.advanceTimersByTime(30000); - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } + await flushPromises(); // Call count should still be 1 expect(callCount).toBe(1); @@ -5651,26 +6410,36 @@ describe('RampsController', () => { }); describe('getWidgetUrl', () => { - it('returns widget URL when present in quote', async () => { - await withController(({ controller }) => { + it('fetches and returns widget URL via RampsService messenger', async () => { + await withController(async ({ controller, rootMessenger }) => { const quote: Quote = { - provider: '/providers/moonpay', + provider: '/providers/transak-staging', quote: { amountIn: 100, amountOut: '0.05', paymentMethod: '/payments/debit-credit-card', - widgetUrl: 'https://buy.moonpay.com/widget?txId=123', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', }, }; - const widgetUrl = controller.getWidgetUrl(quote); + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => ({ + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER' as const, + orderId: null, + }), + ); + + const widgetUrl = await controller.getWidgetUrl(quote); - expect(widgetUrl).toBe('https://buy.moonpay.com/widget?txId=123'); + expect(widgetUrl).toBe('https://global.transak.com/?apiKey=test'); }); }); - it('returns null when widget URL is not present', async () => { - await withController(({ controller }) => { + it('returns null when buyURL is not present', async () => { + await withController(async ({ controller }) => { const quote: Quote = { provider: '/providers/transak', quote: { @@ -5680,112 +6449,1395 @@ describe('RampsController', () => { }, }; - const widgetUrl = controller.getWidgetUrl(quote); + const widgetUrl = await controller.getWidgetUrl(quote); expect(widgetUrl).toBeNull(); }); }); it('returns null when quote object is malformed', async () => { - await withController(({ controller }) => { + await withController(async ({ controller }) => { const quote = { provider: '/providers/moonpay', } as unknown as Quote; - const widgetUrl = controller.getWidgetUrl(quote); + const widgetUrl = await controller.getWidgetUrl(quote); expect(widgetUrl).toBeNull(); }); }); - }); -}); -/** - * Creates a mock UserRegion object for testing. - * - * @param regionCode - The region code (e.g., "us-ca" or "us"). - * @param countryName - Optional country name. If not provided, a default name will be generated. - * @param stateName - Optional state name. If not provided, a default name will be generated. - * @returns A UserRegion object with country and state information. - */ -function createMockUserRegion( - regionCode: string, - countryName?: string, - stateName?: string, -): UserRegion { - const parts = regionCode.toLowerCase().split('-'); - const countryCode = parts[0]; - const stateCode = parts[1]; + it('returns null when service call throws an error', async () => { + await withController(async ({ controller, rootMessenger }) => { + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; - const country: Country = { - isoCode: countryCode.toUpperCase(), - name: countryName ?? `Country ${countryCode.toUpperCase()}`, - flag: '🏳️', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: { buy: true, sell: true }, - ...(stateCode && { - states: [ - { - stateId: stateCode.toUpperCase(), - name: stateName ?? `State ${stateCode.toUpperCase()}`, - supported: { buy: true, sell: true }, - }, - ], - }), - }; + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => { + throw new Error('Network error'); + }, + ); - const state: State | null = stateCode - ? { - stateId: stateCode.toUpperCase(), - name: stateName ?? `State ${stateCode.toUpperCase()}`, - supported: { buy: true, sell: true }, - } - : null; + const widgetUrl = await controller.getWidgetUrl(quote); - return { - country, - state, - regionCode: regionCode.toLowerCase(), - }; -} + expect(widgetUrl).toBeNull(); + }); + }); -/** - * Creates mock countries array for testing. - * - * @returns An array of mock Country objects. - */ -function createMockCountries(): Country[] { - return [ - { - isoCode: 'US', - name: 'United States of America', - flag: '🇺🇸', - currency: 'USD', - phone: { prefix: '+1', placeholder: '', template: '' }, - supported: { buy: true, sell: true }, - states: [ - { - stateId: 'CA', - name: 'California', - supported: { buy: true, sell: true }, - }, - { - stateId: 'NY', - name: 'New York', - supported: { buy: true, sell: true }, - }, - { stateId: 'UT', name: 'Utah', supported: { buy: true, sell: true } }, - ], - }, - { - isoCode: 'FR', - name: 'France', - flag: '🇫🇷', - currency: 'EUR', - phone: { prefix: '+33', placeholder: '', template: '' }, - supported: { buy: true, sell: true }, - }, + it('returns null when service returns BuyWidget with null url', async () => { + await withController(async ({ controller, rootMessenger }) => { + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; + + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => ({ + url: null as unknown as string, + browser: 'APP_BROWSER' as const, + orderId: null, + }), + ); + + const widgetUrl = await controller.getWidgetUrl(quote); + + expect(widgetUrl).toBeNull(); + }); + }); + }); + + describe('Transak methods', () => { + describe('transakSetApiKey', () => { + it('calls messenger with the api key', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn(); + rootMessenger.registerActionHandler( + 'TransakService:setApiKey', + handler, + ); + controller.transakSetApiKey('test-api-key'); + expect(handler).toHaveBeenCalledWith('test-api-key'); + }); + }); + }); + + describe('transakSetAccessToken', () => { + it('calls messenger and sets authenticated to true', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn(); + rootMessenger.registerActionHandler( + 'TransakService:setAccessToken', + handler, + ); + const token: TransakAccessToken = { + accessToken: 'tok', + ttl: 3600, + created: new Date('2024-01-01'), + }; + controller.transakSetAccessToken(token); + expect(handler).toHaveBeenCalledWith(token); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + }); + }); + }); + + describe('transakClearAccessToken', () => { + it('calls messenger and sets authenticated to false', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:setAccessToken', + jest.fn(), + ); + rootMessenger.registerActionHandler( + 'TransakService:clearAccessToken', + jest.fn(), + ); + controller.transakSetAccessToken({ + accessToken: 'tok', + ttl: 3600, + created: new Date('2024-01-01'), + }); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + controller.transakClearAccessToken(); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakSetAuthenticated', () => { + it('sets isAuthenticated in transak state', async () => { + await withController(async ({ controller }) => { + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + controller.transakSetAuthenticated(true); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + controller.transakSetAuthenticated(false); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakResetState', () => { + it('resets all transak state to defaults and clears access token', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:setAccessToken', + jest.fn(), + ); + const clearAccessTokenHandler = jest.fn(); + rootMessenger.registerActionHandler( + 'TransakService:clearAccessToken', + clearAccessTokenHandler, + ); + controller.transakSetAccessToken({ + accessToken: 'tok', + ttl: 3600, + created: new Date('2024-01-01'), + }); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + + controller.transakResetState(); + + expect(clearAccessTokenHandler).toHaveBeenCalled(); + expect(controller.state.nativeProviders.transak) + .toMatchInlineSnapshot(` + { + "buyQuote": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "isAuthenticated": false, + "kycRequirement": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + "userDetails": { + "data": null, + "error": null, + "isLoading": false, + "selected": null, + }, + } + `); + }); + }); + }); + + describe('transakSendUserOtp', () => { + it('calls messenger with email and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockResult = { + isTncAccepted: true, + stateToken: 'state-token', + email: 'test@example.com', + expiresIn: 300, + }; + rootMessenger.registerActionHandler( + 'TransakService:sendUserOtp', + async () => mockResult, + ); + const result = + await controller.transakSendUserOtp('test@example.com'); + expect(result).toStrictEqual(mockResult); + }); + }); + }); + + describe('transakVerifyUserOtp', () => { + it('calls messenger and sets authenticated', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockToken: TransakAccessToken = { + accessToken: 'verified-token', + ttl: 3600, + created: new Date('2024-01-01'), + }; + rootMessenger.registerActionHandler( + 'TransakService:verifyUserOtp', + async () => mockToken, + ); + const result = await controller.transakVerifyUserOtp( + 'test@example.com', + '123456', + 'state-token', + ); + expect(result).toStrictEqual(mockToken); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + }); + }); + }); + + describe('transakLogout', () => { + it('calls messenger and clears authentication and user details on success', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:setAccessToken', + jest.fn(), + ); + const clearAccessTokenHandler = jest.fn(); + rootMessenger.registerActionHandler( + 'TransakService:clearAccessToken', + clearAccessTokenHandler, + ); + rootMessenger.registerActionHandler( + 'TransakService:logout', + async () => 'logged out', + ); + controller.transakSetAccessToken({ + accessToken: 'tok', + ttl: 3600, + created: new Date('2024-01-01'), + }); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + + const result = await controller.transakLogout(); + + expect(result).toBe('logged out'); + expect(clearAccessTokenHandler).toHaveBeenCalled(); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + expect( + controller.state.nativeProviders.transak.userDetails.data, + ).toBeNull(); + }); + }); + + it('clears authentication, access token, and user details even when logout throws', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:setAccessToken', + jest.fn(), + ); + const clearAccessTokenHandler = jest.fn(); + rootMessenger.registerActionHandler( + 'TransakService:clearAccessToken', + clearAccessTokenHandler, + ); + rootMessenger.registerActionHandler( + 'TransakService:logout', + async () => { + throw new Error('Network error'); + }, + ); + controller.transakSetAccessToken({ + accessToken: 'tok', + ttl: 3600, + created: new Date('2024-01-01'), + }); + + await expect(controller.transakLogout()).rejects.toThrow( + 'Network error', + ); + expect(clearAccessTokenHandler).toHaveBeenCalled(); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + expect( + controller.state.nativeProviders.transak.userDetails.data, + ).toBeNull(); + }); + }); + }); + + describe('transakGetUserDetails', () => { + const mockUserDetails: TransakUserDetails = { + id: 'user-1', + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + mobileNumber: '+1234567890', + status: 'active', + dob: '1990-01-01', + kyc: { + status: 'APPROVED', + type: 'L1', + attempts: [], + highestApprovedKYCType: 'L1', + kycMarkedBy: null, + kycResult: null, + rejectionDetails: null, + userId: 'user-1', + workFlowRunId: 'wf-1', + }, + address: { + addressLine1: '123 Main St', + addressLine2: '', + state: 'CA', + city: 'San Francisco', + postCode: '94105', + country: 'United States', + countryCode: 'US', + }, + createdAt: '2024-01-01T00:00:00Z', + }; + + it('fetches user details and updates state on success', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getUserDetails', + async () => mockUserDetails, + ); + const result = await controller.transakGetUserDetails(); + expect(result).toStrictEqual(mockUserDetails); + expect(controller.state.nativeProviders.transak.userDetails) + .toMatchInlineSnapshot(` + { + "data": { + "address": { + "addressLine1": "123 Main St", + "addressLine2": "", + "city": "San Francisco", + "country": "United States", + "countryCode": "US", + "postCode": "94105", + "state": "CA", + }, + "createdAt": "2024-01-01T00:00:00Z", + "dob": "1990-01-01", + "email": "john@example.com", + "firstName": "John", + "id": "user-1", + "kyc": { + "attempts": [], + "highestApprovedKYCType": "L1", + "kycMarkedBy": null, + "kycResult": null, + "rejectionDetails": null, + "status": "APPROVED", + "type": "L1", + "userId": "user-1", + "workFlowRunId": "wf-1", + }, + "lastName": "Doe", + "mobileNumber": "+1234567890", + "status": "active", + }, + "error": null, + "isLoading": false, + "selected": null, + } + `); + }); + }); + + it('sets error state when fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getUserDetails', + async () => { + throw new Error('Auth failed'); + }, + ); + await expect(controller.transakGetUserDetails()).rejects.toThrow( + 'Auth failed', + ); + expect( + controller.state.nativeProviders.transak.userDetails.isLoading, + ).toBe(false); + expect( + controller.state.nativeProviders.transak.userDetails.error, + ).toBe('Auth failed'); + }); + }); + + it('uses fallback error message when error has no message', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getUserDetails', + async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw null; + }, + ); + await expect(controller.transakGetUserDetails()).rejects.toBeNull(); + expect( + controller.state.nativeProviders.transak.userDetails.error, + ).toBe('Unknown error'); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getUserDetails', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect(controller.transakGetUserDetails()).rejects.toThrow( + 'Token expired', + ); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + + it('does not change isAuthenticated for non-401 errors', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getUserDetails', + async () => { + throw Object.assign(new Error('Server error'), { + httpStatus: 500, + }); + }, + ); + await expect(controller.transakGetUserDetails()).rejects.toThrow( + 'Server error', + ); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + true, + ); + }); + }); + }); + + describe('transakGetBuyQuote', () => { + const mockBuyQuote: TransakBuyQuote = { + quoteId: 'quote-1', + conversionPrice: 50000, + marketConversionPrice: 50100, + slippage: 0.5, + fiatCurrency: 'USD', + cryptoCurrency: 'BTC', + paymentMethod: 'credit_debit_card', + fiatAmount: 100, + cryptoAmount: 0.002, + isBuyOrSell: 'BUY', + network: 'bitcoin', + feeDecimal: 0.01, + totalFee: 1, + feeBreakdown: [], + nonce: 1, + cryptoLiquidityProvider: 'provider-1', + notes: [], + }; + + it('fetches buy quote and updates state on success', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => mockBuyQuote, + ); + const result = await controller.transakGetBuyQuote( + 'USD', + 'BTC', + 'bitcoin', + 'credit_debit_card', + '100', + ); + expect(result).toStrictEqual(mockBuyQuote); + expect(controller.state.nativeProviders.transak.buyQuote) + .toMatchInlineSnapshot(` + { + "data": { + "conversionPrice": 50000, + "cryptoAmount": 0.002, + "cryptoCurrency": "BTC", + "cryptoLiquidityProvider": "provider-1", + "feeBreakdown": [], + "feeDecimal": 0.01, + "fiatAmount": 100, + "fiatCurrency": "USD", + "isBuyOrSell": "BUY", + "marketConversionPrice": 50100, + "network": "bitcoin", + "nonce": 1, + "notes": [], + "paymentMethod": "credit_debit_card", + "quoteId": "quote-1", + "slippage": 0.5, + "totalFee": 1, + }, + "error": null, + "isLoading": false, + "selected": null, + } + `); + }); + }); + + it('sets error state when fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => { + throw new Error('Quote failed'); + }, + ); + await expect( + controller.transakGetBuyQuote( + 'USD', + 'BTC', + 'bitcoin', + 'credit_debit_card', + '100', + ), + ).rejects.toThrow('Quote failed'); + expect( + controller.state.nativeProviders.transak.buyQuote.isLoading, + ).toBe(false); + expect(controller.state.nativeProviders.transak.buyQuote.error).toBe( + 'Quote failed', + ); + }); + }); + + it('uses fallback error message when error has no message', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw null; + }, + ); + await expect( + controller.transakGetBuyQuote( + 'USD', + 'BTC', + 'bitcoin', + 'credit_debit_card', + '100', + ), + ).rejects.toBeNull(); + expect(controller.state.nativeProviders.transak.buyQuote.error).toBe( + 'Unknown error', + ); + }); + }); + }); + + describe('transakGetKycRequirement', () => { + const mockKycRequirement: TransakKycRequirement = { + status: 'APPROVED', + kycType: 'L1', + isAllowedToPlaceOrder: true, + }; + + it('fetches KYC requirement and updates state on success', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getKycRequirement', + async () => mockKycRequirement, + ); + const result = await controller.transakGetKycRequirement('quote-1'); + expect(result).toStrictEqual(mockKycRequirement); + expect(controller.state.nativeProviders.transak.kycRequirement) + .toMatchInlineSnapshot(` + { + "data": { + "isAllowedToPlaceOrder": true, + "kycType": "L1", + "status": "APPROVED", + }, + "error": null, + "isLoading": false, + "selected": null, + } + `); + }); + }); + + it('sets error state when fetch fails', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getKycRequirement', + async () => { + throw new Error('KYC failed'); + }, + ); + await expect( + controller.transakGetKycRequirement('quote-1'), + ).rejects.toThrow('KYC failed'); + expect( + controller.state.nativeProviders.transak.kycRequirement.isLoading, + ).toBe(false); + expect( + controller.state.nativeProviders.transak.kycRequirement.error, + ).toBe('KYC failed'); + }); + }); + + it('uses fallback error message when error has no message', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:getKycRequirement', + async () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw null; + }, + ); + await expect( + controller.transakGetKycRequirement('quote-1'), + ).rejects.toBeNull(); + expect( + controller.state.nativeProviders.transak.kycRequirement.error, + ).toBe('Unknown error'); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getKycRequirement', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakGetKycRequirement('quote-1'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakGetAdditionalRequirements', () => { + it('calls messenger with quoteId and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockResult: TransakAdditionalRequirementsResponse = { + formsRequired: [], + }; + rootMessenger.registerActionHandler( + 'TransakService:getAdditionalRequirements', + async () => mockResult, + ); + const result = + await controller.transakGetAdditionalRequirements('quote-1'); + expect(result).toStrictEqual(mockResult); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getAdditionalRequirements', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakGetAdditionalRequirements('quote-1'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakCreateOrder', () => { + it('calls messenger with correct arguments and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockOrder = createMockDepositOrder(); + const handler = jest.fn().mockResolvedValue(mockOrder); + rootMessenger.registerActionHandler( + 'TransakService:createOrder', + handler, + ); + const result = await controller.transakCreateOrder( + 'quote-1', + '0x123', + '/payments/debit-credit-card', + ); + expect(handler).toHaveBeenCalledWith( + 'quote-1', + '0x123', + '/payments/debit-credit-card', + ); + expect(result).toStrictEqual(mockOrder); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:createOrder', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakCreateOrder('quote-1', '0x123', 'card'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakGetOrder', () => { + it('calls messenger with orderId and wallet', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockOrder = createMockDepositOrder(); + rootMessenger.registerActionHandler( + 'TransakService:getOrder', + async () => mockOrder, + ); + const result = await controller.transakGetOrder('order-1', '0x123'); + expect(result).toStrictEqual(mockOrder); + }); + }); + + it('passes optional paymentDetails to messenger', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockOrder = createMockDepositOrder(); + const paymentDetails: TransakOrderPaymentMethod[] = [ + { + fiatCurrency: 'USD', + paymentMethod: 'credit_debit_card', + fields: [], + }, + ]; + const handler = jest.fn().mockResolvedValue(mockOrder); + rootMessenger.registerActionHandler( + 'TransakService:getOrder', + handler, + ); + await controller.transakGetOrder('order-1', '0x123', paymentDetails); + expect(handler).toHaveBeenCalledWith( + 'order-1', + '0x123', + paymentDetails, + ); + }); + }); + }); + + describe('transakGetUserLimits', () => { + it('calls messenger with correct arguments and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockLimits: TransakUserLimits = { + limits: { '1': 500, '30': 5000, '365': 50000 }, + spent: { '1': 0, '30': 0, '365': 0 }, + remaining: { '1': 500, '30': 5000, '365': 50000 }, + exceeded: { '1': false, '30': false, '365': false }, + shortage: {}, + }; + rootMessenger.registerActionHandler( + 'TransakService:getUserLimits', + async () => mockLimits, + ); + const result = await controller.transakGetUserLimits( + 'USD', + 'credit_debit_card', + 'L1', + ); + expect(result).toStrictEqual(mockLimits); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getUserLimits', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakGetUserLimits('USD', 'card', 'L1'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakRequestOtt', () => { + it('calls messenger and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockResult: TransakOttResponse = { ott: 'ott-token-123' }; + rootMessenger.registerActionHandler( + 'TransakService:requestOtt', + async () => mockResult, + ); + const result = await controller.transakRequestOtt(); + expect(result).toStrictEqual(mockResult); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:requestOtt', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect(controller.transakRequestOtt()).rejects.toThrow( + 'Token expired', + ); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakGeneratePaymentWidgetUrl', () => { + const mockQuote: TransakBuyQuote = { + quoteId: 'quote-1', + conversionPrice: 50000, + marketConversionPrice: 50100, + slippage: 0.5, + fiatCurrency: 'USD', + cryptoCurrency: 'BTC', + paymentMethod: 'credit_debit_card', + fiatAmount: 100, + cryptoAmount: 0.002, + isBuyOrSell: 'BUY', + network: 'bitcoin', + feeDecimal: 0.01, + totalFee: 1, + feeBreakdown: [], + nonce: 1, + cryptoLiquidityProvider: 'provider-1', + notes: [], + }; + + it('calls messenger with correct arguments and returns URL', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'TransakService:generatePaymentWidgetUrl', + () => 'https://widget.transak.com?param=value', + ); + const result = controller.transakGeneratePaymentWidgetUrl( + 'ott-token', + mockQuote, + '0x123', + ); + expect(result).toBe('https://widget.transak.com?param=value'); + }); + }); + + it('passes optional extraParams to messenger', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest + .fn() + .mockReturnValue('https://widget.transak.com'); + rootMessenger.registerActionHandler( + 'TransakService:generatePaymentWidgetUrl', + handler, + ); + const extraParams = { themeColor: 'blue' }; + controller.transakGeneratePaymentWidgetUrl( + 'ott-token', + mockQuote, + '0x123', + extraParams, + ); + expect(handler).toHaveBeenCalledWith( + 'ott-token', + mockQuote, + '0x123', + extraParams, + ); + }); + }); + }); + + describe('transakSubmitPurposeOfUsageForm', () => { + it('calls messenger with purpose array', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn().mockResolvedValue(undefined); + rootMessenger.registerActionHandler( + 'TransakService:submitPurposeOfUsageForm', + handler, + ); + await controller.transakSubmitPurposeOfUsageForm([ + 'investment', + 'trading', + ]); + expect(handler).toHaveBeenCalledWith(['investment', 'trading']); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:submitPurposeOfUsageForm', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakSubmitPurposeOfUsageForm(['investment']), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakPatchUser', () => { + it('calls messenger with user data and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const data: PatchUserRequestBody = { + personalDetails: { firstName: 'Jane', lastName: 'Doe' }, + }; + const handler = jest.fn().mockResolvedValue({ success: true }); + rootMessenger.registerActionHandler( + 'TransakService:patchUser', + handler, + ); + const result = await controller.transakPatchUser(data); + expect(handler).toHaveBeenCalledWith(data); + expect(result).toStrictEqual({ success: true }); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:patchUser', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakPatchUser({ + personalDetails: { firstName: 'Jane' }, + }), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakSubmitSsnDetails', () => { + it('calls messenger with ssn and quoteId', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn().mockResolvedValue({ success: true }); + rootMessenger.registerActionHandler( + 'TransakService:submitSsnDetails', + handler, + ); + const result = await controller.transakSubmitSsnDetails( + '123-45-6789', + 'quote-1', + ); + expect(handler).toHaveBeenCalledWith('123-45-6789', 'quote-1'); + expect(result).toStrictEqual({ success: true }); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:submitSsnDetails', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakSubmitSsnDetails('123-45-6789', 'quote-1'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakConfirmPayment', () => { + it('calls messenger with orderId and paymentMethodId', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn().mockResolvedValue({ success: true }); + rootMessenger.registerActionHandler( + 'TransakService:confirmPayment', + handler, + ); + const result = await controller.transakConfirmPayment( + 'order-1', + '/payments/debit-credit-card', + ); + expect(handler).toHaveBeenCalledWith( + 'order-1', + '/payments/debit-credit-card', + ); + expect(result).toStrictEqual({ success: true }); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:confirmPayment', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakConfirmPayment('order-1', 'card'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakGetTranslation', () => { + it('calls messenger with translation request and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockTranslation: TransakQuoteTranslation = { + region: 'US', + paymentMethod: 'credit_debit_card', + cryptoCurrency: 'BTC', + network: 'bitcoin', + fiatCurrency: 'USD', + }; + const request: TransakTranslationRequest = { + cryptoCurrencyId: 'BTC', + chainId: 'bitcoin', + fiatCurrencyId: 'USD', + paymentMethod: 'credit_debit_card', + }; + rootMessenger.registerActionHandler( + 'TransakService:getTranslation', + async () => mockTranslation, + ); + const result = await controller.transakGetTranslation(request); + expect(result).toStrictEqual(mockTranslation); + }); + }); + }); + + describe('transakGetIdProofStatus', () => { + it('calls messenger with workFlowRunId and returns result', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockStatus: TransakIdProofStatus = { + status: 'SUBMITTED', + kycType: 'L1', + randomLogIdentifier: 'log-123', + }; + rootMessenger.registerActionHandler( + 'TransakService:getIdProofStatus', + async () => mockStatus, + ); + const result = await controller.transakGetIdProofStatus('wf-run-1'); + expect(result).toStrictEqual(mockStatus); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getIdProofStatus', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakGetIdProofStatus('wf-run-1'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakCancelOrder', () => { + it('calls messenger with depositOrderId', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn().mockResolvedValue(undefined); + rootMessenger.registerActionHandler( + 'TransakService:cancelOrder', + handler, + ); + await controller.transakCancelOrder( + '/providers/transak-native/orders/order-1', + ); + expect(handler).toHaveBeenCalledWith( + '/providers/transak-native/orders/order-1', + ); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:cancelOrder', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakCancelOrder('order-1'), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakCancelAllActiveOrders', () => { + it('calls messenger and returns collected errors', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handler = jest.fn().mockResolvedValue([]); + rootMessenger.registerActionHandler( + 'TransakService:cancelAllActiveOrders', + handler, + ); + const errors = await controller.transakCancelAllActiveOrders(); + expect(handler).toHaveBeenCalled(); + expect(errors).toStrictEqual([]); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:cancelAllActiveOrders', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect( + controller.transakCancelAllActiveOrders(), + ).rejects.toThrow('Token expired'); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + + describe('transakGetActiveOrders', () => { + it('calls messenger and returns orders', async () => { + await withController(async ({ controller, rootMessenger }) => { + const mockOrders: TransakOrder[] = [ + { + orderId: 'order-1', + partnerUserId: 'user-1', + status: 'PROCESSING', + isBuyOrSell: 'BUY', + fiatCurrency: 'USD', + cryptoCurrency: 'BTC', + network: 'bitcoin', + walletAddress: '0x123', + quoteId: 'quote-1', + fiatAmount: 100, + fiatAmountInUsd: 100, + amountPaid: 100, + cryptoAmount: 0.002, + conversionPrice: 50000, + totalFeeInFiat: 1, + paymentDetails: [], + txHash: '', + transationLink: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:01:00Z', + completedAt: '', + }, + ]; + rootMessenger.registerActionHandler( + 'TransakService:getActiveOrders', + async () => mockOrders, + ); + const result = await controller.transakGetActiveOrders(); + expect(result).toStrictEqual(mockOrders); + }); + }); + + it('sets isAuthenticated to false when a 401 HttpError is thrown', async () => { + await withController(async ({ controller, rootMessenger }) => { + controller.transakSetAuthenticated(true); + rootMessenger.registerActionHandler( + 'TransakService:getActiveOrders', + async () => { + throw Object.assign(new Error('Token expired'), { + httpStatus: 401, + }); + }, + ); + await expect(controller.transakGetActiveOrders()).rejects.toThrow( + 'Token expired', + ); + expect(controller.state.nativeProviders.transak.isAuthenticated).toBe( + false, + ); + }); + }); + }); + }); +}); + +/** + * Creates a mock UserRegion object for testing. + * + * @param regionCode - The region code (e.g., "us-ca" or "us"). + * @param countryName - Optional country name. If not provided, a default name will be generated. + * @param stateName - Optional state name. If not provided, a default name will be generated. + * @returns A UserRegion object with country and state information. + */ +function createMockUserRegion( + regionCode: string, + countryName?: string, + stateName?: string, +): UserRegion { + const parts = regionCode.toLowerCase().split('-'); + const countryCode = parts[0]; + const stateCode = parts[1]; + + const country: Country = { + isoCode: countryCode.toUpperCase(), + name: countryName ?? `Country ${countryCode.toUpperCase()}`, + flag: '🏳️', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: { buy: true, sell: true }, + ...(stateCode && { + states: [ + { + stateId: stateCode.toUpperCase(), + name: stateName ?? `State ${stateCode.toUpperCase()}`, + supported: { buy: true, sell: true }, + }, + ], + }), + }; + + const state: State | null = stateCode + ? { + stateId: stateCode.toUpperCase(), + name: stateName ?? `State ${stateCode.toUpperCase()}`, + supported: { buy: true, sell: true }, + } + : null; + + return { + country, + state, + regionCode: regionCode.toLowerCase(), + }; +} + +/** + * Creates mock countries array for testing. + * + * @returns An array of mock Country objects. + */ +function createMockCountries(): Country[] { + return [ + { + isoCode: 'US', + name: 'United States of America', + flag: '🇺🇸', + currency: 'USD', + phone: { prefix: '+1', placeholder: '', template: '' }, + supported: { buy: true, sell: true }, + states: [ + { + stateId: 'CA', + name: 'California', + supported: { buy: true, sell: true }, + }, + { + stateId: 'NY', + name: 'New York', + supported: { buy: true, sell: true }, + }, + { stateId: 'UT', name: 'Utah', supported: { buy: true, sell: true } }, + ], + }, + { + isoCode: 'FR', + name: 'France', + flag: '🇫🇷', + currency: 'EUR', + phone: { prefix: '+33', placeholder: '', template: '' }, + supported: { buy: true, sell: true }, + }, ]; } @@ -5808,6 +7860,51 @@ function createResourceState( }; } +function createMockDepositOrder(): TransakDepositOrder { + return { + id: '/providers/transak-native/orders/order-1', + provider: 'transak-native', + cryptoAmount: 0.002, + fiatAmount: 100, + cryptoCurrency: { + assetId: 'eip155:1/slip44:60', + name: 'Bitcoin', + chainId: '1', + decimals: 8, + iconUrl: 'https://example.com/btc.png', + symbol: 'BTC', + }, + fiatCurrency: 'USD', + providerOrderId: 'transak-order-1', + providerOrderLink: 'https://transak.com/order/1', + createdAt: 1704067200000, + paymentMethod: { + id: 'credit_debit_card', + name: 'Credit/Debit Card', + duration: '5-10 min', + icon: 'https://example.com/card.png', + }, + totalFeesFiat: 1, + txHash: '', + walletAddress: '0x123', + status: 'PROCESSING', + network: { name: 'Bitcoin', chainId: '1' }, + timeDescriptionPending: '5-10 minutes', + fiatAmountInUsd: 100, + feesInUsd: 1, + region: { + isoCode: 'US', + flag: '🇺🇸', + name: 'United States', + phone: { prefix: '+1', placeholder: '', template: '' }, + currency: 'USD', + supported: true, + }, + orderType: 'DEPOSIT', + paymentDetails: [], + }; +} + /** * The type of the messenger populated with all external actions and events * required by the controller under test. @@ -5820,7 +7917,8 @@ type RootMessenger = Messenger< | RampsServiceGetTokensAction | RampsServiceGetProvidersAction | RampsServiceGetPaymentMethodsAction - | RampsServiceGetQuotesAction, + | RampsServiceGetQuotesAction + | RampsServiceGetBuyWidgetUrlAction, MessengerEvents >; @@ -5895,3 +7993,12 @@ async function withController( }); return await testFunction({ controller, rootMessenger, messenger }); } + +/** + * Flushes pending microtasks by yielding to the event loop multiple times. + */ +async function flushPromises(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 7cf40c29f88..5c8612135c0 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,6 +6,7 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; +import type { Draft } from 'immer'; import type { Country, @@ -17,9 +18,9 @@ import type { PaymentMethodsResponse, QuotesResponse, Quote, - GetQuotesParams, RampsToken, RampsServiceActions, + BuyWidget, } from './RampsService'; import type { RampsServiceGetGeolocationAction, @@ -28,6 +29,7 @@ import type { RampsServiceGetProvidersAction, RampsServiceGetPaymentMethodsAction, RampsServiceGetQuotesAction, + RampsServiceGetBuyWidgetUrlAction, } from './RampsService-method-action-types'; import type { RequestCache as RequestCacheType, @@ -46,6 +48,49 @@ import { createErrorState, RequestStatus, } from './RequestCache'; +import type { + TransakAccessToken, + TransakUserDetails, + TransakBuyQuote, + TransakKycRequirement, + TransakAdditionalRequirementsResponse, + TransakDepositOrder, + TransakUserLimits, + TransakOttResponse, + TransakQuoteTranslation, + TransakTranslationRequest, + TransakIdProofStatus, + TransakOrderPaymentMethod, + PatchUserRequestBody, + TransakOrder, +} from './TransakService'; +import type { TransakServiceActions } from './TransakService'; +import type { + TransakServiceSetApiKeyAction, + TransakServiceSetAccessTokenAction, + TransakServiceClearAccessTokenAction, + TransakServiceSendUserOtpAction, + TransakServiceVerifyUserOtpAction, + TransakServiceLogoutAction, + TransakServiceGetUserDetailsAction, + TransakServiceGetBuyQuoteAction, + TransakServiceGetKycRequirementAction, + TransakServiceGetAdditionalRequirementsAction, + TransakServiceCreateOrderAction, + TransakServiceGetOrderAction, + TransakServiceGetUserLimitsAction, + TransakServiceRequestOttAction, + TransakServiceGeneratePaymentWidgetUrlAction, + TransakServiceSubmitPurposeOfUsageFormAction, + TransakServicePatchUserAction, + TransakServiceSubmitSsnDetailsAction, + TransakServiceConfirmPaymentAction, + TransakServiceGetTranslationAction, + TransakServiceGetIdProofStatusAction, + TransakServiceCancelOrderAction, + TransakServiceCancelAllActiveOrdersAction, + TransakServiceGetActiveOrdersAction, +} from './TransakService-method-action-types'; // === GENERAL === @@ -61,15 +106,42 @@ export const controllerName = 'RampsController'; * Any host (e.g. mobile) that creates a RampsController messenger must delegate * these actions from the root messenger so the controller can function. */ -export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly RampsServiceActions['type'][] = - [ - 'RampsService:getGeolocation', - 'RampsService:getCountries', - 'RampsService:getTokens', - 'RampsService:getProviders', - 'RampsService:getPaymentMethods', - 'RampsService:getQuotes', - ]; +export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( + | RampsServiceActions['type'] + | TransakServiceActions['type'] +)[] = [ + 'RampsService:getGeolocation', + 'RampsService:getCountries', + 'RampsService:getTokens', + 'RampsService:getProviders', + 'RampsService:getPaymentMethods', + 'RampsService:getQuotes', + 'RampsService:getBuyWidgetUrl', + 'TransakService:setApiKey', + 'TransakService:setAccessToken', + 'TransakService:clearAccessToken', + 'TransakService:sendUserOtp', + 'TransakService:verifyUserOtp', + 'TransakService:logout', + 'TransakService:getUserDetails', + 'TransakService:getBuyQuote', + 'TransakService:getKycRequirement', + 'TransakService:getAdditionalRequirements', + 'TransakService:createOrder', + 'TransakService:getOrder', + 'TransakService:getUserLimits', + 'TransakService:requestOtt', + 'TransakService:generatePaymentWidgetUrl', + 'TransakService:submitPurposeOfUsageForm', + 'TransakService:patchUser', + 'TransakService:submitSsnDetails', + 'TransakService:confirmPayment', + 'TransakService:getTranslation', + 'TransakService:getIdProofStatus', + 'TransakService:cancelOrder', + 'TransakService:cancelAllActiveOrders', + 'TransakService:getActiveOrders', +]; /** * Default TTL for quotes requests (15 seconds). @@ -122,6 +194,25 @@ export type ResourceState = { error: string | null; }; +/** + * Describes the transak-specific state managed by the RampsController. + * This state is used by the unified V2 native flow. + */ +export type TransakState = { + isAuthenticated: boolean; + userDetails: ResourceState; + buyQuote: ResourceState; + kycRequirement: ResourceState; +}; + +/** + * Describes the state for all native providers managed by the RampsController. + * Each native provider has its own nested state object. + */ +export type NativeProvidersState = { + transak: TransakState; +}; + /** * Describes the shape of the state object for {@link RampsController}. */ @@ -157,11 +248,23 @@ export type RampsControllerState = { * Selected contains the currently selected quote for the user. */ quotes: ResourceState; + /** + * Widget URL resource state with data, loading, and error. + * Contains the buy widget data (URL, browser type, order ID) for the currently selected quote. + * Automatically fetched whenever a selected quote changes. + */ + widgetUrl: ResourceState; /** * Cache of request states, keyed by cache key. * This stores loading, success, and error states for API requests. */ requests: RequestCacheType; + /** + * State for native providers in the unified V2 flow. + * Each provider has its own nested state containing authentication, + * user details, quote, and KYC data. + */ + nativeProviders: NativeProvidersState; }; /** @@ -204,12 +307,24 @@ const rampsControllerMetadata = { includeInStateLogs: false, usedInUi: true, }, + widgetUrl: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, requests: { persist: false, includeInDebugSnapshot: true, includeInStateLogs: false, usedInUi: true, }, + nativeProviders: { + persist: false, + includeInDebugSnapshot: true, + includeInStateLogs: false, + usedInUi: true, + }, } satisfies StateMetadata; /** @@ -261,10 +376,60 @@ export function getDefaultRampsControllerState(): RampsControllerState { null, null, ), + widgetUrl: createDefaultResourceState(null), requests: {}, + nativeProviders: { + transak: { + isAuthenticated: false, + userDetails: createDefaultResourceState( + null, + ), + buyQuote: createDefaultResourceState(null), + kycRequirement: + createDefaultResourceState(null), + }, + }, }; } +const DEPENDENT_RESOURCE_KEYS = [ + 'providers', + 'tokens', + 'paymentMethods', + 'quotes', +] as const; + +type DependentResourceKey = (typeof DEPENDENT_RESOURCE_KEYS)[number]; + +const DEPENDENT_RESOURCE_KEYS_SET = new Set(DEPENDENT_RESOURCE_KEYS); + +function resetResource( + state: Draft, + resourceType: DependentResourceKey, + defaultResource?: RampsControllerState[DependentResourceKey], +): void { + const def = defaultResource ?? getDefaultRampsControllerState()[resourceType]; + const resource = state[resourceType]; + resource.data = def.data; + resource.selected = def.selected; + resource.isLoading = def.isLoading; + resource.error = def.error; +} + +/** + * Resets the widgetUrl resource to its default state. + * Mutates state in place; use from within controller update() for atomic updates. + * + * @param state - The state object to mutate. + */ +function resetWidgetUrl(state: Draft): void { + const def = getDefaultRampsControllerState().widgetUrl; + state.widgetUrl.data = def.data; + state.widgetUrl.selected = def.selected; + state.widgetUrl.isLoading = def.isLoading; + state.widgetUrl.error = def.error; +} + /** * Resets region-dependent resources (userRegion, providers, tokens, paymentMethods, quotes). * Mutates state in place; use from within controller update() for atomic updates. @@ -274,28 +439,17 @@ export function getDefaultRampsControllerState(): RampsControllerState { * @param options.clearUserRegionData - When true, sets userRegion to null (e.g. for full cleanup). */ function resetDependentResources( - state: RampsControllerState, + state: Draft, options?: { clearUserRegionData?: boolean }, ): void { if (options?.clearUserRegionData) { state.userRegion = null; } - state.providers.selected = null; - state.providers.data = []; - state.providers.isLoading = false; - state.providers.error = null; - state.tokens.selected = null; - state.tokens.data = null; - state.tokens.isLoading = false; - state.tokens.error = null; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; - state.paymentMethods.isLoading = false; - state.paymentMethods.error = null; - state.quotes.data = null; - state.quotes.selected = null; - state.quotes.isLoading = false; - state.quotes.error = null; + const defaultState = getDefaultRampsControllerState(); + for (const key of DEPENDENT_RESOURCE_KEYS) { + resetResource(state, key, defaultState[key]); + } + resetWidgetUrl(state); } // === MESSENGER === @@ -322,7 +476,32 @@ type AllowedActions = | RampsServiceGetTokensAction | RampsServiceGetProvidersAction | RampsServiceGetPaymentMethodsAction - | RampsServiceGetQuotesAction; + | RampsServiceGetQuotesAction + | RampsServiceGetBuyWidgetUrlAction + | TransakServiceSetApiKeyAction + | TransakServiceSetAccessTokenAction + | TransakServiceClearAccessTokenAction + | TransakServiceSendUserOtpAction + | TransakServiceVerifyUserOtpAction + | TransakServiceLogoutAction + | TransakServiceGetUserDetailsAction + | TransakServiceGetBuyQuoteAction + | TransakServiceGetKycRequirementAction + | TransakServiceGetAdditionalRequirementsAction + | TransakServiceCreateOrderAction + | TransakServiceGetOrderAction + | TransakServiceGetUserLimitsAction + | TransakServiceRequestOttAction + | TransakServiceGeneratePaymentWidgetUrlAction + | TransakServiceSubmitPurposeOfUsageFormAction + | TransakServicePatchUserAction + | TransakServiceSubmitSsnDetailsAction + | TransakServiceConfirmPaymentAction + | TransakServiceGetTranslationAction + | TransakServiceGetIdProofStatusAction + | TransakServiceCancelOrderAction + | TransakServiceCancelAllActiveOrdersAction + | TransakServiceGetActiveOrdersAction; /** * Published when the state of {@link RampsController} changes. @@ -489,17 +668,24 @@ export class RampsController extends BaseController< } #clearPendingResourceCountForDependentResources(): void { - const types: ResourceType[] = [ - 'providers', - 'tokens', - 'paymentMethods', - 'quotes', - ]; - for (const resourceType of types) { + for (const resourceType of DEPENDENT_RESOURCE_KEYS) { this.#pendingResourceCount.delete(resourceType); } } + #abortDependentRequests(): void { + for (const [cacheKey, pending] of this.#pendingRequests.entries()) { + if ( + pending.resourceType && + DEPENDENT_RESOURCE_KEYS_SET.has(pending.resourceType) + ) { + pending.abortController.abort(); + this.#pendingRequests.delete(cacheKey); + this.#removeRequestState(cacheKey); + } + } + } + /** * Constructs a new {@link RampsController}. * @@ -533,30 +719,47 @@ export class RampsController extends BaseController< } /** - * Executes a request with caching and deduplication. + * Executes a request with caching, deduplication, and at most one in-flight + * request per resource type. * - * If a request with the same cache key is already in flight, returns the - * existing promise. If valid cached data exists, returns it without making - * a new request. + * 1. **Same cache key in flight** – If a request with this cache key is + * already pending, returns that promise (deduplication; no second request). * - * @param cacheKey - Unique identifier for this request. - * @param fetcher - Function that performs the actual fetch. Receives an AbortSignal. - * @param options - Options for cache behavior. - * @returns The result of the request. + * 2. **Cache hit** – If valid, non-expired data exists in state.requests for + * this key and forceRefresh is not set, returns that data without fetching. + * + * 3. **New request** – Creates an AbortController and fires the fetcher. + * If options.resourceType is set, tags the pending request with that + * resource type (so #abortDependentRequests can cancel it on region + * change or cleanup) and ref-counts resource-level loading state. + * On success or error, updates request state and resource error; + * in finally, clears resource loading only if this request was not + * aborted. + * + * @param cacheKey - Unique identifier for this request (e.g. from createCacheKey). + * @param fetcher - Async function that performs the fetch. Receives an AbortSignal + * that is aborted when this request is superseded by another for the same resource. + * @param options - Optional forceRefresh, ttl, and resourceType for loading/error state. + * @returns The result of the request (from cache, joined promise, or fetcher). */ async executeRequest( cacheKey: string, fetcher: (signal: AbortSignal) => Promise, options?: ExecuteRequestOptions, ): Promise { + // Get TTL for verifying cache expiration const ttl = options?.ttl ?? this.#requestCacheTTL; - // Check for existing pending request - join it instead of making a duplicate + // DEDUPLICATION: + // Check if a request is already in flight for this cache key + // If so, return the original promise for that request const pending = this.#pendingRequests.get(cacheKey); if (pending) { return pending.promise as Promise; } + // CACHE HIT: + // If cache is not expired, return the cached data if (!options?.forceRefresh) { const cached = this.state.requests[cacheKey]; if (cached && !isCacheExpired(cached, ttl)) { @@ -564,7 +767,8 @@ export class RampsController extends BaseController< } } - // Create abort controller for this request + // Create a new abort controller for this request + // Record the time the request was started const abortController = new AbortController(); const lastFetchedAt = Date.now(); const { resourceType } = options ?? {}; @@ -587,7 +791,6 @@ export class RampsController extends BaseController< try { const data = await fetcher(abortController.signal); - // Don't update state if aborted if (abortController.signal.aborted) { throw new Error('Request was aborted'); } @@ -598,30 +801,23 @@ export class RampsController extends BaseController< ); if (resourceType) { - // We need the extra logic because there are two situations where we’re allowed to clear the error: - // No callback → always clear - // Callback present → clear only when isResultCurrent() returns true. const isCurrent = !options?.isResultCurrent || options.isResultCurrent(); if (isCurrent) { this.#setResourceError(resourceType, null); } } - return data; } catch (error) { - // Don't update state if aborted if (abortController.signal.aborted) { throw error; } const errorMessage = (error as Error)?.message ?? 'Unknown error'; - this.#updateRequestState( cacheKey, createErrorState(errorMessage, lastFetchedAt), ); - if (resourceType) { const isCurrent = !options?.isResultCurrent || options.isResultCurrent(); @@ -629,17 +825,17 @@ export class RampsController extends BaseController< this.#setResourceError(resourceType, errorMessage); } } - throw error; } finally { - // Only delete if this is still our entry (not replaced by a new request) - const currentPending = this.#pendingRequests.get(cacheKey); - if (currentPending?.abortController === abortController) { + if ( + this.#pendingRequests.get(cacheKey)?.abortController === + abortController + ) { this.#pendingRequests.delete(cacheKey); } // Clear resource-level loading state only when no requests for this resource remain - if (resourceType) { + if (resourceType && !abortController.signal.aborted) { const count = this.#pendingResourceCount.get(resourceType) ?? 0; const next = Math.max(0, count - 1); if (next === 0) { @@ -652,8 +848,11 @@ export class RampsController extends BaseController< } })(); - // Store pending request for deduplication - this.#pendingRequests.set(cacheKey, { promise, abortController }); + this.#pendingRequests.set(cacheKey, { + promise, + abortController, + resourceType, + }); return promise; } @@ -676,27 +875,34 @@ export class RampsController extends BaseController< } /** - * Removes a request state from the cache. + * Mutates state.requests inside update(); cast is centralized here. * - * @param cacheKey - The cache key to remove. + * @param fn - Callback that mutates the requests record. */ - #removeRequestState(cacheKey: string): void { + #mutateRequests( + fn: (requests: Record) => void, + ): void { this.update((state) => { const requests = state.requests as unknown as Record< string, RequestState | undefined >; + fn(requests); + }); + } + + #removeRequestState(cacheKey: string): void { + this.#mutateRequests((requests) => { delete requests[cacheKey]; }); } #cleanupState(): void { this.stopQuotePolling(); + this.#abortDependentRequests(); this.#clearPendingResourceCountForDependentResources(); this.update((state) => - resetDependentResources(state as unknown as RampsControllerState, { - clearUserRegionData: true, - }), + resetDependentResources(state, { clearUserRegionData: true }), ); } @@ -728,6 +934,31 @@ export class RampsController extends BaseController< } } + #requireRegion(): string { + const regionCode = this.state.userRegion?.regionCode; + if (!regionCode) { + throw new Error( + 'Region is required. Cannot proceed without valid region information.', + ); + } + return regionCode; + } + + #isRegionCurrent(normalizedRegion: string): boolean { + const current = this.state.userRegion?.regionCode; + return current === undefined || current === normalizedRegion; + } + + #isTokenCurrent(normalizedAssetId: string): boolean { + const current = this.state.tokens.selected?.assetId ?? ''; + return current === normalizedAssetId; + } + + #isProviderCurrent(normalizedProviderId: string): boolean { + const current = this.state.providers.selected?.id ?? ''; + return current === normalizedProviderId; + } + /** * Updates a single field (isLoading or error) on a resource state. * All resources share the same ResourceState structure, so we use @@ -789,16 +1020,8 @@ export class RampsController extends BaseController< #updateRequestState(cacheKey: string, requestState: RequestState): void { const maxSize = this.#requestCacheMaxSize; const ttl = this.#requestCacheTTL; - - this.update((state) => { - const requests = state.requests as unknown as Record< - string, - RequestState | undefined - >; + this.#mutateRequests((requests) => { requests[cacheKey] = requestState; - - // Evict expired entries based on TTL - // Only evict SUCCESS states that have exceeded their TTL const keys = Object.keys(requests); for (const key of keys) { const entry = requests[key]; @@ -809,18 +1032,13 @@ export class RampsController extends BaseController< delete requests[key]; } } - - // Evict oldest entries if cache still exceeds max size const remainingKeys = Object.keys(requests); if (remainingKeys.length > maxSize) { - // Sort by timestamp (oldest first) const sortedKeys = remainingKeys.sort((a, b) => { const aTime = requests[a]?.timestamp ?? 0; const bTime = requests[b]?.timestamp ?? 0; return aTime - bTime; }); - - // Remove oldest entries until we're under the limit const entriesToRemove = remainingKeys.length - maxSize; for (let i = 0; i < entriesToRemove; i++) { const keyToRemove = sortedKeys[i]; @@ -873,14 +1091,13 @@ export class RampsController extends BaseController< this.state.providers.data.length === 0; if (regionChanged) { + this.#abortDependentRequests(); this.#clearPendingResourceCountForDependentResources(); - } - if (regionChanged) { this.stopQuotePolling(); } this.update((state) => { if (regionChanged) { - resetDependentResources(state as unknown as RampsControllerState); + resetDependentResources(state); } state.userRegion = userRegion; }); @@ -922,19 +1139,12 @@ export class RampsController extends BaseController< this.stopQuotePolling(); this.update((state) => { state.providers.selected = null; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; + resetResource(state, 'paymentMethods'); }); return; } - const regionCode = this.state.userRegion?.regionCode; - if (!regionCode) { - throw new Error( - 'Region is required. Cannot set selected provider without valid region information.', - ); - } - + const regionCode = this.#requireRegion(); const providers = this.state.providers.data; if (!providers || providers.length === 0) { throw new Error( @@ -951,9 +1161,9 @@ export class RampsController extends BaseController< this.update((state) => { state.providers.selected = provider; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; + resetResource(state, 'paymentMethods'); state.quotes.selected = null; + resetWidgetUrl(state); }); this.#fireAndForget( @@ -991,12 +1201,7 @@ export class RampsController extends BaseController< } hydrateState(options?: ExecuteRequestOptions): void { - const regionCode = this.state.userRegion?.regionCode; - if (!regionCode) { - throw new Error( - 'Region code is required. Cannot hydrate state without valid region information.', - ); - } + const regionCode = this.#requireRegion(); this.#fireAndForget(this.getTokens(regionCode, 'buy', options)); this.#fireAndForget(this.getProviders(regionCode, options)); @@ -1022,7 +1227,7 @@ export class RampsController extends BaseController< ); this.update((state) => { - state.countries.data = countries; + state.countries.data = Array.isArray(countries) ? [...countries] : []; }); return countries; @@ -1045,13 +1250,7 @@ export class RampsController extends BaseController< provider?: string | string[]; }, ): Promise { - const regionToUse = region ?? this.state.userRegion?.regionCode; - - if (!regionToUse) { - throw new Error( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', - ); - } + const regionToUse = region ?? this.#requireRegion(); const normalizedRegion = regionToUse.toLowerCase().trim(); const cacheKey = createCacheKey('getTokens', [ @@ -1075,9 +1274,7 @@ export class RampsController extends BaseController< { ...options, resourceType: 'tokens', - isResultCurrent: () => - this.state.userRegion?.regionCode === undefined || - this.state.userRegion?.regionCode === normalizedRegion, + isResultCurrent: () => this.#isRegionCurrent(normalizedRegion), }, ); @@ -1105,19 +1302,12 @@ export class RampsController extends BaseController< this.stopQuotePolling(); this.update((state) => { state.tokens.selected = null; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; + resetResource(state, 'paymentMethods'); }); return; } - const regionCode = this.state.userRegion?.regionCode; - if (!regionCode) { - throw new Error( - 'Region is required. Cannot set selected token without valid region information.', - ); - } - + const regionCode = this.#requireRegion(); const tokens = this.state.tokens.data; if (!tokens) { throw new Error( @@ -1137,9 +1327,9 @@ export class RampsController extends BaseController< this.update((state) => { state.tokens.selected = token; - state.paymentMethods.data = []; - state.paymentMethods.selected = null; + resetResource(state, 'paymentMethods'); state.quotes.selected = null; + resetWidgetUrl(state); }); this.#fireAndForget( @@ -1173,13 +1363,7 @@ export class RampsController extends BaseController< payments?: string | string[]; }, ): Promise<{ providers: Provider[] }> { - const regionToUse = region ?? this.state.userRegion?.regionCode; - - if (!regionToUse) { - throw new Error( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', - ); - } + const regionToUse = region ?? this.#requireRegion(); const normalizedRegion = regionToUse.toLowerCase().trim(); const cacheKey = createCacheKey('getProviders', [ @@ -1207,9 +1391,7 @@ export class RampsController extends BaseController< { ...options, resourceType: 'providers', - isResultCurrent: () => - this.state.userRegion?.regionCode === undefined || - this.state.userRegion?.regionCode === normalizedRegion, + isResultCurrent: () => this.#isRegionCurrent(normalizedRegion), }, ); @@ -1243,7 +1425,7 @@ export class RampsController extends BaseController< provider?: string; }, ): Promise { - const regionCode = region ?? this.state.userRegion?.regionCode ?? null; + const regionCode = region ?? this.#requireRegion(); const fiatToUse = options?.fiat ?? this.state.userRegion?.country?.currency ?? null; const assetIdToUse = @@ -1251,12 +1433,6 @@ export class RampsController extends BaseController< const providerToUse = options?.provider ?? this.state.providers.selected?.id ?? ''; - if (!regionCode) { - throw new Error( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', - ); - } - if (!fiatToUse) { throw new Error( 'Fiat currency is required. Either provide a fiat parameter or ensure userRegion is set in controller state.', @@ -1286,13 +1462,9 @@ export class RampsController extends BaseController< ...options, resourceType: 'paymentMethods', isResultCurrent: () => { - const regionMatch = - this.state.userRegion?.regionCode === undefined || - this.state.userRegion?.regionCode === normalizedRegion; - const tokenMatch = - (this.state.tokens.selected?.assetId ?? '') === assetIdToUse; - const providerMatch = - (this.state.providers.selected?.id ?? '') === providerToUse; + const regionMatch = this.#isRegionCurrent(normalizedRegion); + const tokenMatch = this.#isTokenCurrent(assetIdToUse); + const providerMatch = this.#isProviderCurrent(providerToUse); return regionMatch && tokenMatch && providerMatch; }, }, @@ -1374,7 +1546,7 @@ export class RampsController extends BaseController< * @param options.amount - The amount (in fiat for buy, crypto for sell). * @param options.walletAddress - The destination wallet address. * @param options.paymentMethods - Array of payment method IDs. If not provided, uses paymentMethods from state. - * @param options.provider - Optional provider ID to filter quotes. + * @param options.providers - Optional provider IDs to filter quotes. * @param options.redirectUrl - Optional redirect URL after order completion. * @param options.action - The ramp action type. Defaults to 'buy'. * @param options.forceRefresh - Whether to bypass cache. @@ -1384,28 +1556,26 @@ export class RampsController extends BaseController< async getQuotes(options: { region?: string; fiat?: string; - assetId: string; + assetId?: string; amount: number; walletAddress: string; paymentMethods?: string[]; - provider?: string; + providers?: string[]; redirectUrl?: string; action?: RampAction; forceRefresh?: boolean; ttl?: number; }): Promise { - const regionToUse = options.region ?? this.state.userRegion?.regionCode; + const regionToUse = options.region ?? this.#requireRegion(); const fiatToUse = options.fiat ?? this.state.userRegion?.country?.currency; const paymentMethodsToUse = options.paymentMethods ?? this.state.paymentMethods.data.map((pm: PaymentMethod) => pm.id); + const providersToUse = + options.providers ?? + this.state.providers.data.map((provider: Provider) => provider.id); const action = options.action ?? 'buy'; - - if (!regionToUse) { - throw new Error( - 'Region is required. Either provide a region parameter or ensure userRegion is set in controller state.', - ); - } + const assetIdToUse = options.assetId ?? this.state.tokens.selected?.assetId; if (!fiatToUse) { throw new Error( @@ -1413,7 +1583,16 @@ export class RampsController extends BaseController< ); } - if (!paymentMethodsToUse || paymentMethodsToUse.length === 0) { + const normalizedAssetIdForValidation = (assetIdToUse ?? '').trim(); + if (normalizedAssetIdForValidation === '') { + throw new Error('assetId is required.'); + } + + if ( + !paymentMethodsToUse || + paymentMethodsToUse.length === 0 || + paymentMethodsToUse.some((pm) => pm.trim() === '') + ) { throw new Error( 'Payment methods are required. Either provide paymentMethods parameter or ensure paymentMethods are set in controller state.', ); @@ -1423,17 +1602,13 @@ export class RampsController extends BaseController< throw new Error('Amount must be a positive finite number.'); } - if (!options.assetId || options.assetId.trim() === '') { - throw new Error('assetId is required.'); - } - if (!options.walletAddress || options.walletAddress.trim() === '') { throw new Error('walletAddress is required.'); } const normalizedRegion = regionToUse.toLowerCase().trim(); const normalizedFiat = fiatToUse.toLowerCase().trim(); - const normalizedAssetId = options.assetId.trim(); + const normalizedAssetId = normalizedAssetIdForValidation; const normalizedWalletAddress = options.walletAddress.trim(); const cacheKey = createCacheKey('getQuotes', [ @@ -1443,19 +1618,19 @@ export class RampsController extends BaseController< options.amount, normalizedWalletAddress, [...paymentMethodsToUse].sort().join(','), - options.provider, + [...providersToUse].sort().join(','), options.redirectUrl, action, ]); - const params: GetQuotesParams = { + const params = { region: normalizedRegion, fiat: normalizedFiat, assetId: normalizedAssetId, amount: options.amount, walletAddress: normalizedWalletAddress, paymentMethods: paymentMethodsToUse, - provider: options.provider, + providers: providersToUse, redirectUrl: options.redirectUrl, action, }; @@ -1469,9 +1644,7 @@ export class RampsController extends BaseController< forceRefresh: options.forceRefresh, ttl: options.ttl ?? DEFAULT_QUOTES_TTL, resourceType: 'quotes', - isResultCurrent: () => - this.state.userRegion?.regionCode === undefined || - this.state.userRegion?.regionCode === normalizedRegion, + isResultCurrent: () => this.#isRegionCurrent(normalizedRegion), }, ); @@ -1492,29 +1665,25 @@ export class RampsController extends BaseController< * If the response contains exactly one quote, it is auto-selected. * If multiple quotes are returned, the existing selection is preserved if still valid. * + * Returns early (no-op) if the selected payment method is not yet set, + * allowing callers to invoke this before payment-method selection is finalized. + * * @param options - Parameters for fetching quotes. * @param options.walletAddress - The destination wallet address. * @param options.amount - The amount (in fiat for buy, crypto for sell). * @param options.redirectUrl - Optional redirect URL after order completion. - * @throws If required dependencies (region, token, provider, payment method) are not set. + * @throws If required dependencies (region, token, provider) are not set. */ startQuotePolling(options: { walletAddress: string; amount: number; redirectUrl?: string; }): void { - // Validate required dependencies - const regionCode = this.state.userRegion?.regionCode; + this.#requireRegion(); const token = this.state.tokens.selected; const provider = this.state.providers.selected; const paymentMethod = this.state.paymentMethods.selected; - if (!regionCode) { - throw new Error( - 'Region is required. Cannot start quote polling without valid region information.', - ); - } - if (!token) { throw new Error( 'Token is required. Cannot start quote polling without a selected token.', @@ -1546,13 +1715,16 @@ export class RampsController extends BaseController< walletAddress: options.walletAddress, redirectUrl: options.redirectUrl, paymentMethods: [paymentMethod.id], - provider: provider.id, + providers: [provider.id], forceRefresh: true, }).then((response) => { + let newSelectedQuote: Quote | null = null; + // Auto-select logic: only when exactly one quote is returned this.update((state) => { if (response.success.length === 1) { - state.quotes.selected = response.success[0]; + newSelectedQuote = response.success[0]; + state.quotes.selected = newSelectedQuote; } else { // Keep existing selection if still valid, but update with fresh data const currentSelection = state.quotes.selected; @@ -1563,11 +1735,13 @@ export class RampsController extends BaseController< quote.quote.paymentMethod === currentSelection.quote.paymentMethod, ); - // Update with fresh quote data, or clear if no longer valid - state.quotes.selected = freshQuote ?? null; + newSelectedQuote = freshQuote ?? null; + state.quotes.selected = newSelectedQuote; } } }); + + this.#syncWidgetUrl(newSelectedQuote); return undefined; }), ); @@ -1594,6 +1768,7 @@ export class RampsController extends BaseController< /** * Manually sets the selected quote. + * Automatically triggers a widget URL fetch for the new quote. * * @param quote - The quote to select, or null to clear the selection. */ @@ -1601,6 +1776,7 @@ export class RampsController extends BaseController< this.update((state) => { state.quotes.selected = quote; }); + this.#syncWidgetUrl(quote); } /** @@ -1614,13 +1790,610 @@ export class RampsController extends BaseController< } /** - * Extracts the widget URL from a quote for redirect providers. - * Returns the widget URL if available, or null if the quote doesn't have one. + * Syncs the widget URL state with the given quote. + * If the quote has a buyURL, fetches the widget URL and stores the result in state. + * If the quote is null or has no buyURL, resets the widget URL state. + * + * When data already exists, skips the loading-state reset so that polling + * cycles don't cause visible flicker (stale-while-revalidate). + * + * @param quote - The quote to fetch the widget URL for, or null to clear. + */ + #syncWidgetUrl(quote: Quote | null): void { + const buyUrl = quote?.quote?.buyURL; + if (!buyUrl) { + this.update((state) => { + resetWidgetUrl(state); + }); + return; + } + + if (this.state.widgetUrl.data === null) { + this.update((state) => { + state.widgetUrl.isLoading = true; + state.widgetUrl.error = null; + }); + } + + this.#fireAndForget( + this.messenger + .call('RampsService:getBuyWidgetUrl', buyUrl) + .then((buyWidget) => { + this.update((state) => { + state.widgetUrl.data = buyWidget; + state.widgetUrl.isLoading = false; + state.widgetUrl.error = null; + }); + return undefined; + }) + .catch((error: unknown) => { + this.update((state) => { + state.widgetUrl.isLoading = false; + state.widgetUrl.error = + error instanceof Error + ? error.message + : 'Failed to fetch widget URL'; + }); + }), + ); + } + + /** + * Fetches the widget URL from a quote for redirect providers. + * Makes a request to the buyURL endpoint via the RampsService to get the + * actual provider widget URL, using the injected fetch and retry policy. + * + * @param quote - The quote to fetch the widget URL from. + * @returns Promise resolving to the widget URL string, or null if not available. + * @deprecated Read `state.widgetUrl` instead. The widget URL is now automatically + * fetched and stored in state whenever the selected quote changes. + */ + async getWidgetUrl(quote: Quote): Promise { + const buyUrl = quote.quote?.buyURL; + if (!buyUrl) { + return null; + } + + try { + const buyWidget = await this.messenger.call( + 'RampsService:getBuyWidgetUrl', + buyUrl, + ); + return buyWidget.url ?? null; + } catch { + return null; + } + } + + // === TRANSAK METHODS === + // + // Auth state is managed at two levels: + // - TransakService stores the access token (needed for API calls) + // - RampsController stores isAuthenticated (needed for UI state) + // Both are kept in sync by the controller methods below. + + /** + * Checks whether an error is a 401 HTTP error (expired/missing token) and, + * if so, marks the Transak session as unauthenticated so the UI stays in + * sync with the cleared token inside TransakService. + * + * @param error - The caught error to inspect. + */ + #syncTransakAuthOnError(error: unknown): void { + if ( + error instanceof Error && + 'httpStatus' in error && + (error as Error & { httpStatus: number }).httpStatus === 401 + ) { + this.transakSetAuthenticated(false); + } + } + + /** + * Sets the Transak API key used for all Transak API requests. + * + * @param apiKey - The Transak API key. + */ + transakSetApiKey(apiKey: string): void { + this.messenger.call('TransakService:setApiKey', apiKey); + } + + /** + * Sets the Transak access token and marks the user as authenticated. + * + * @param token - The access token received from Transak auth. + */ + transakSetAccessToken(token: TransakAccessToken): void { + this.messenger.call('TransakService:setAccessToken', token); + this.transakSetAuthenticated(true); + } + + /** + * Clears the Transak access token and marks the user as unauthenticated. + */ + transakClearAccessToken(): void { + this.messenger.call('TransakService:clearAccessToken'); + this.transakSetAuthenticated(false); + } + + /** + * Updates the Transak authentication flag in controller state. + * + * @param isAuthenticated - Whether the user is authenticated with Transak. + */ + transakSetAuthenticated(isAuthenticated: boolean): void { + this.update((state) => { + state.nativeProviders.transak.isAuthenticated = isAuthenticated; + }); + } + + /** + * Resets all Transak state back to defaults (unauthenticated, no data). + */ + transakResetState(): void { + this.messenger.call('TransakService:clearAccessToken'); + this.update((state) => { + state.nativeProviders.transak = + getDefaultRampsControllerState().nativeProviders.transak; + }); + } + + /** + * Sends a one-time password to the user's email for Transak authentication. + * + * @param email - The user's email address. + * @returns The OTP response containing a state token for verification. + */ + async transakSendUserOtp(email: string): Promise<{ + isTncAccepted: boolean; + stateToken: string; + email: string; + expiresIn: number; + }> { + return this.messenger.call('TransakService:sendUserOtp', email); + } + + /** + * Verifies a one-time password and authenticates the user with Transak. + * Updates the controller's authentication state on success. + * + * @param email - The user's email address. + * @param verificationCode - The OTP code entered by the user. + * @param stateToken - The state token from the sendUserOtp response. + * @returns The access token for subsequent authenticated requests. + */ + async transakVerifyUserOtp( + email: string, + verificationCode: string, + stateToken: string, + ): Promise { + const token = await this.messenger.call( + 'TransakService:verifyUserOtp', + email, + verificationCode, + stateToken, + ); + this.transakSetAuthenticated(true); + return token; + } + + /** + * Logs the user out of Transak. Clears authentication state and user details + * regardless of whether the API call succeeds or fails. + * + * @returns A message indicating the logout result. + */ + async transakLogout(): Promise { + try { + const result = await this.messenger.call('TransakService:logout'); + return result; + } finally { + this.transakClearAccessToken(); + this.update((state) => { + state.nativeProviders.transak.userDetails.data = null; + }); + } + } + + /** + * Fetches the authenticated user's details from Transak. + * Updates the userDetails resource state with loading/success/error states. + * + * @returns The user's profile and KYC details. + */ + async transakGetUserDetails(): Promise { + this.update((state) => { + state.nativeProviders.transak.userDetails.isLoading = true; + state.nativeProviders.transak.userDetails.error = null; + }); + try { + const details = await this.messenger.call( + 'TransakService:getUserDetails', + ); + this.update((state) => { + state.nativeProviders.transak.userDetails.data = details; + state.nativeProviders.transak.userDetails.isLoading = false; + }); + return details; + } catch (error) { + this.#syncTransakAuthOnError(error); + const errorMessage = (error as Error)?.message ?? 'Unknown error'; + this.update((state) => { + state.nativeProviders.transak.userDetails.isLoading = false; + state.nativeProviders.transak.userDetails.error = errorMessage; + }); + throw error; + } + } + + /** + * Fetches a buy quote from Transak for the given parameters. + * Updates the buyQuote resource state with loading/success/error states. + * + * @param fiatCurrency - The fiat currency code (e.g., "USD"). + * @param cryptoCurrency - The cryptocurrency identifier. + * @param network - The blockchain network identifier. + * @param paymentMethod - The payment method identifier. + * @param fiatAmount - The fiat amount as a string. + * @returns The buy quote with pricing and fee details. + */ + async transakGetBuyQuote( + fiatCurrency: string, + cryptoCurrency: string, + network: string, + paymentMethod: string, + fiatAmount: string, + ): Promise { + this.update((state) => { + state.nativeProviders.transak.buyQuote.isLoading = true; + state.nativeProviders.transak.buyQuote.error = null; + }); + try { + const quote = await this.messenger.call( + 'TransakService:getBuyQuote', + fiatCurrency, + cryptoCurrency, + network, + paymentMethod, + fiatAmount, + ); + this.update((state) => { + state.nativeProviders.transak.buyQuote.data = quote; + state.nativeProviders.transak.buyQuote.isLoading = false; + }); + return quote; + } catch (error) { + const errorMessage = (error as Error)?.message ?? 'Unknown error'; + this.update((state) => { + state.nativeProviders.transak.buyQuote.isLoading = false; + state.nativeProviders.transak.buyQuote.error = errorMessage; + }); + throw error; + } + } + + /** + * Fetches the KYC requirement for a given quote. + * Updates the kycRequirement resource state with loading/success/error states. + * + * @param quoteId - The quote ID to check KYC requirements for. + * @returns The KYC requirement status and whether the user can place an order. + */ + async transakGetKycRequirement( + quoteId: string, + ): Promise { + this.update((state) => { + state.nativeProviders.transak.kycRequirement.isLoading = true; + state.nativeProviders.transak.kycRequirement.error = null; + }); + try { + const requirement = await this.messenger.call( + 'TransakService:getKycRequirement', + quoteId, + ); + this.update((state) => { + state.nativeProviders.transak.kycRequirement.data = requirement; + state.nativeProviders.transak.kycRequirement.isLoading = false; + }); + return requirement; + } catch (error) { + this.#syncTransakAuthOnError(error); + const errorMessage = (error as Error)?.message ?? 'Unknown error'; + this.update((state) => { + state.nativeProviders.transak.kycRequirement.isLoading = false; + state.nativeProviders.transak.kycRequirement.error = errorMessage; + }); + throw error; + } + } + + /** + * Fetches additional KYC requirements (e.g., ID proof, address proof) for a quote. + * + * @param quoteId - The quote ID to check additional requirements for. + * @returns The list of additional forms required. + */ + async transakGetAdditionalRequirements( + quoteId: string, + ): Promise { + try { + return await this.messenger.call( + 'TransakService:getAdditionalRequirements', + quoteId, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Creates a new order on Transak. If an existing order conflicts (HTTP 409), + * active orders are cancelled and the creation is retried. + * + * @param quoteId - The quote ID to create an order from. + * @param walletAddress - The destination wallet address. + * @param paymentMethodId - The payment method to use. + * @returns The created deposit order. + */ + async transakCreateOrder( + quoteId: string, + walletAddress: string, + paymentMethodId: string, + ): Promise { + try { + return await this.messenger.call( + 'TransakService:createOrder', + quoteId, + walletAddress, + paymentMethodId, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Fetches an existing order from Transak by order ID. + * + * @param orderId - The order ID (deposit format or raw Transak format). + * @param wallet - The wallet address associated with the order. + * @param paymentDetails - Optional payment details to attach to the order. + * @returns The deposit order details. + */ + async transakGetOrder( + orderId: string, + wallet: string, + paymentDetails?: TransakOrderPaymentMethod[], + ): Promise { + return this.messenger.call( + 'TransakService:getOrder', + orderId, + wallet, + paymentDetails, + ); + } + + /** + * Fetches the user's spending limits for a given currency and payment method. + * + * @param fiatCurrency - The fiat currency code. + * @param paymentMethod - The payment method identifier. + * @param kycType - The KYC level type. + * @returns The user's limits, spending, and remaining amounts. + */ + async transakGetUserLimits( + fiatCurrency: string, + paymentMethod: string, + kycType: string, + ): Promise { + try { + return await this.messenger.call( + 'TransakService:getUserLimits', + fiatCurrency, + paymentMethod, + kycType, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Requests a one-time token (OTT) for the Transak payment widget. + * + * @returns The OTT response containing the token. + */ + async transakRequestOtt(): Promise { + try { + return await this.messenger.call('TransakService:requestOtt'); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Generates a URL for the Transak payment widget with pre-filled parameters. + * + * @param ottToken - The one-time token for widget authentication. + * @param quote - The buy quote to pre-fill in the widget. + * @param walletAddress - The destination wallet address. + * @param extraParams - Optional additional URL parameters. + * @returns The fully constructed widget URL string. + */ + transakGeneratePaymentWidgetUrl( + ottToken: string, + quote: TransakBuyQuote, + walletAddress: string, + extraParams?: Record, + ): string { + return this.messenger.call( + 'TransakService:generatePaymentWidgetUrl', + ottToken, + quote, + walletAddress, + extraParams, + ); + } + + /** + * Submits the user's purpose of usage form for KYC compliance. + * + * @param purpose - Array of purpose strings selected by the user. + * @returns A promise that resolves when the form is submitted. + */ + async transakSubmitPurposeOfUsageForm(purpose: string[]): Promise { + try { + return await this.messenger.call( + 'TransakService:submitPurposeOfUsageForm', + purpose, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Updates the user's personal or address details on Transak. + * + * @param data - The user data fields to update. + * @returns The API response data. + */ + async transakPatchUser(data: PatchUserRequestBody): Promise { + try { + return await this.messenger.call('TransakService:patchUser', data); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Submits the user's SSN for identity verification. + * + * @param ssn - The Social Security Number. + * @param quoteId - The quote ID associated with the order requiring SSN. + * @returns The API response data. + */ + async transakSubmitSsnDetails( + ssn: string, + quoteId: string, + ): Promise { + try { + return await this.messenger.call( + 'TransakService:submitSsnDetails', + ssn, + quoteId, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Confirms payment for an order after the user has completed payment. + * + * @param orderId - The order ID to confirm payment for. + * @param paymentMethodId - The payment method used. + * @returns Whether the payment confirmation was successful. + */ + async transakConfirmPayment( + orderId: string, + paymentMethodId: string, + ): Promise<{ success: boolean }> { + try { + return await this.messenger.call( + 'TransakService:confirmPayment', + orderId, + paymentMethodId, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Translates generic ramps identifiers to Transak-specific identifiers. + * + * @param request - The translation request with optional identifiers to translate. + * @returns The translated Transak-specific identifiers. + */ + async transakGetTranslation( + request: TransakTranslationRequest, + ): Promise { + return this.messenger.call('TransakService:getTranslation', request); + } + + /** + * Checks the status of an ID proof submission for KYC. + * + * @param workFlowRunId - The workflow run ID to check status for. + * @returns The current ID proof status. + */ + async transakGetIdProofStatus( + workFlowRunId: string, + ): Promise { + try { + return await this.messenger.call( + 'TransakService:getIdProofStatus', + workFlowRunId, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Cancels a specific Transak order. * - * @param quote - The quote to extract the widget URL from. - * @returns The widget URL string, or null if not available. + * @param depositOrderId - The deposit order ID to cancel. + * @returns A promise that resolves when the order is cancelled. */ - getWidgetUrl(quote: Quote): string | null { - return quote.quote?.widgetUrl ?? null; + async transakCancelOrder(depositOrderId: string): Promise { + try { + return await this.messenger.call( + 'TransakService:cancelOrder', + depositOrderId, + ); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Cancels all active Transak orders. Individual cancellation failures + * are collected and returned rather than thrown. + * + * @returns An array of errors from any failed cancellations (empty if all succeeded). + */ + async transakCancelAllActiveOrders(): Promise { + try { + return await this.messenger.call('TransakService:cancelAllActiveOrders'); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } + } + + /** + * Fetches all active Transak orders for the authenticated user. + * + * @returns The list of active orders. + */ + async transakGetActiveOrders(): Promise { + try { + return await this.messenger.call('TransakService:getActiveOrders'); + } catch (error) { + this.#syncTransakAuthOnError(error); + throw error; + } } } diff --git a/packages/ramps-controller/src/RampsService-method-action-types.ts b/packages/ramps-controller/src/RampsService-method-action-types.ts index 8158db63240..6765d66bf86 100644 --- a/packages/ramps-controller/src/RampsService-method-action-types.ts +++ b/packages/ramps-controller/src/RampsService-method-action-types.ts @@ -87,7 +87,7 @@ export type RampsServiceGetPaymentMethodsAction = { * @param params.amount - The amount (in fiat for buy, crypto for sell). * @param params.walletAddress - The destination wallet address. * @param params.redirectUrl - Optional redirect URL after order completion. - * @param params.provider - Optional provider ID to filter quotes. + * @param params.providers - Optional provider IDs to filter quotes. * @param params.action - The ramp action type. Defaults to 'buy'. * @returns The quotes response containing success, sorted, error, and customActions. */ @@ -96,6 +96,19 @@ export type RampsServiceGetQuotesAction = { handler: RampsService['getQuotes']; }; +/** + * Fetches the buy widget data from a buy URL endpoint. + * Makes a request to the buyURL (as provided in a quote) to get the actual + * provider widget URL, browser type, and order ID. + * + * @param buyUrl - The full buy URL endpoint to fetch from. + * @returns The buy widget data containing the provider widget URL. + */ +export type RampsServiceGetBuyWidgetUrlAction = { + type: `RampsService:getBuyWidgetUrl`; + handler: RampsService['getBuyWidgetUrl']; +}; + /** * Union of all RampsService action types. */ @@ -105,4 +118,5 @@ export type RampsServiceMethodActions = | RampsServiceGetTokensAction | RampsServiceGetProvidersAction | RampsServiceGetPaymentMethodsAction - | RampsServiceGetQuotesAction; + | RampsServiceGetQuotesAction + | RampsServiceGetBuyWidgetUrlAction; diff --git a/packages/ramps-controller/src/RampsService.test.ts b/packages/ramps-controller/src/RampsService.test.ts index 9fac0bc7983..663af0dc833 100644 --- a/packages/ramps-controller/src/RampsService.test.ts +++ b/packages/ramps-controller/src/RampsService.test.ts @@ -5,8 +5,6 @@ import type { MessengerEvents, } from '@metamask/messenger'; import nock from 'nock'; -import { useFakeTimers } from 'sinon'; -import type { SinonFakeTimers } from 'sinon'; import type { RampsServiceMessenger } from './RampsService'; import { RampsService, RampsEnvironment } from './RampsService'; @@ -16,14 +14,12 @@ import packageJson from '../package.json'; const CONTROLLER_VERSION = packageJson.version; describe('RampsService', () => { - let clock: SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('RampsService:getGeolocation', () => { @@ -41,7 +37,7 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -64,7 +60,7 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -87,7 +83,7 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -110,7 +106,7 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -133,7 +129,7 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -154,7 +150,7 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(geolocationPromise).rejects.toThrow( 'Malformed response received from geolocation API', @@ -173,13 +169,13 @@ describe('RampsService', () => { .reply(500, 'Internal Server Error'); const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(geolocationPromise).rejects.toThrow( `Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, @@ -208,9 +204,9 @@ describe('RampsService', () => { const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.tickAsync(6000); + await jest.advanceTimersByTimeAsync(6000); await flushPromises(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -230,13 +226,13 @@ describe('RampsService', () => { .reply(500); const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const geolocationPromise = rootMessenger.call( 'RampsService:getGeolocation', ); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(geolocationPromise).rejects.toThrow( `Fetching 'https://on-ramp.uat-api.cx.metamask.io/geolocation?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, @@ -260,7 +256,7 @@ describe('RampsService', () => { }, }); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); await expect( @@ -282,7 +278,7 @@ describe('RampsService', () => { const { service } = getService(); const geolocationPromise = service.getGeolocation(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const geolocationResponse = await geolocationPromise; @@ -331,39 +327,39 @@ describe('RampsService', () => { const { rootMessenger } = getService(); const countriesPromise = rootMessenger.call('RampsService:getCountries'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "currency": "USD", "flag": "🇺🇸", "isoCode": "US", "name": "United States of America", - "phone": Object { + "phone": { "placeholder": "(555) 123-4567", "prefix": "+1", "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": Object { + "supported": { "buy": true, "sell": true, }, }, - Object { + { "currency": "EUR", "flag": "🇦🇹", "isoCode": "AT", "name": "Austria", - "phone": Object { + "phone": { "placeholder": "660 1234567", "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": Object { + "supported": { "buy": true, "sell": false, }, @@ -386,39 +382,39 @@ describe('RampsService', () => { }); const countriesPromise = rootMessenger.call('RampsService:getCountries'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "currency": "USD", "flag": "🇺🇸", "isoCode": "US", "name": "United States of America", - "phone": Object { + "phone": { "placeholder": "(555) 123-4567", "prefix": "+1", "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": Object { + "supported": { "buy": true, "sell": true, }, }, - Object { + { "currency": "EUR", "flag": "🇦🇹", "isoCode": "AT", "name": "Austria", - "phone": Object { + "phone": { "placeholder": "660 1234567", "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": Object { + "supported": { "buy": true, "sell": false, }, @@ -441,39 +437,39 @@ describe('RampsService', () => { }); const countriesPromise = rootMessenger.call('RampsService:getCountries'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "currency": "USD", "flag": "🇺🇸", "isoCode": "US", "name": "United States of America", - "phone": Object { + "phone": { "placeholder": "(555) 123-4567", "prefix": "+1", "template": "(XXX) XXX-XXXX", }, "recommended": true, - "supported": Object { + "supported": { "buy": true, "sell": true, }, }, - Object { + { "currency": "EUR", "flag": "🇦🇹", "isoCode": "AT", "name": "Austria", - "phone": Object { + "phone": { "placeholder": "660 1234567", "prefix": "+43", "template": "XXX XXXXXXX", }, - "supported": Object { + "supported": { "buy": true, "sell": false, }, @@ -519,7 +515,7 @@ describe('RampsService', () => { const { service } = getService(); const countriesPromise = service.getCountries(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -567,7 +563,7 @@ describe('RampsService', () => { const { service } = getService(); const countriesPromise = service.getCountries(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -587,11 +583,11 @@ describe('RampsService', () => { .reply(500); const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const countriesPromise = rootMessenger.call('RampsService:getCountries'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( `Fetching 'https://on-ramp-cache.uat-api.cx.metamask.io/v2/regions/countries?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, @@ -610,7 +606,7 @@ describe('RampsService', () => { const { rootMessenger } = getService(); const countriesPromise = rootMessenger.call('RampsService:getCountries'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( 'Malformed response received from countries API', @@ -629,7 +625,7 @@ describe('RampsService', () => { const { rootMessenger } = getService(); const countriesPromise = rootMessenger.call('RampsService:getCountries'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(countriesPromise).rejects.toThrow( 'Malformed response received from countries API', @@ -660,23 +656,23 @@ describe('RampsService', () => { const { service } = getService(); const countriesPromise = service.getCountries(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; expect(countriesResponse).toMatchInlineSnapshot(` - Array [ - Object { + [ + { "currency": "USD", "flag": "🇺🇸", "isoCode": "US", "name": "United States", - "phone": Object { + "phone": { "placeholder": "", "prefix": "+1", "template": "", }, - "supported": Object { + "supported": { "buy": true, "sell": true, }, @@ -726,7 +722,7 @@ describe('RampsService', () => { const { service } = getService(); const countriesPromise = service.getCountries(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -775,7 +771,7 @@ describe('RampsService', () => { const { service } = getService(); const countriesPromise = service.getCountries(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -820,7 +816,7 @@ describe('RampsService', () => { const { service } = getService(); const countriesPromise = service.getCountries(); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const countriesResponse = await countriesPromise; @@ -879,14 +875,14 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const tokensResponse = await tokensPromise; expect(tokensResponse).toMatchInlineSnapshot(` - Object { - "allTokens": Array [ - Object { + { + "allTokens": [ + { "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "chainId": "eip155:1", "decimals": 6, @@ -896,8 +892,8 @@ describe('RampsService', () => { "tokenSupported": true, }, ], - "topTokens": Array [ - Object { + "topTokens": [ + { "assetId": "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "chainId": "eip155:1", "decimals": 6, @@ -936,7 +932,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const tokensResponse = await tokensPromise; @@ -969,7 +965,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('US', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const tokensResponse = await tokensPromise; @@ -1002,7 +998,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us', 'sell'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const tokensResponse = await tokensPromise; @@ -1031,7 +1027,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(tokensPromise).rejects.toThrow( @@ -1060,7 +1056,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(tokensPromise).rejects.toThrow( @@ -1089,7 +1085,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(tokensPromise).rejects.toThrow( @@ -1118,7 +1114,7 @@ describe('RampsService', () => { const { service } = getService(); const tokensPromise = service.getTokens('us', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(tokensPromise).rejects.toThrow( @@ -1154,7 +1150,7 @@ describe('RampsService', () => { const tokensPromise = service.getTokens('us', 'buy', { provider: 'provider-id', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const tokensResponse = await tokensPromise; @@ -1190,7 +1186,7 @@ describe('RampsService', () => { const tokensPromise = service.getTokens('us', 'buy', { provider: ['provider-id-1', 'provider-id-2'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const tokensResponse = await tokensPromise; @@ -1211,11 +1207,11 @@ describe('RampsService', () => { .reply(500, 'Internal Server Error'); const { service } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const tokensPromise = service.getTokens('us', 'buy'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(tokensPromise).rejects.toThrow( @@ -1255,7 +1251,7 @@ describe('RampsService', () => { const { service } = getService(); const providersPromise = service.getProviders('us'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const providersResponse = await providersPromise; @@ -1280,7 +1276,7 @@ describe('RampsService', () => { const { service } = getService(); const providersPromise = service.getProviders('US'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const providersResponse = await providersPromise; @@ -1311,7 +1307,7 @@ describe('RampsService', () => { fiat: 'USD', payments: 'card', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const providersResponse = await providersPromise; @@ -1338,7 +1334,7 @@ describe('RampsService', () => { provider: ['paypal', 'ramp'], crypto: ['ETH', 'BTC'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const providersResponse = await providersPromise; @@ -1365,7 +1361,7 @@ describe('RampsService', () => { fiat: 'USD', payments: 'card', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const providersResponse = await providersPromise; @@ -1392,7 +1388,7 @@ describe('RampsService', () => { fiat: ['USD', 'EUR'], payments: ['card', 'bank'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const providersResponse = await providersPromise; @@ -1411,7 +1407,7 @@ describe('RampsService', () => { const { service } = getService(); const providersPromise = service.getProviders('us'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(providersPromise).rejects.toThrow( @@ -1431,7 +1427,7 @@ describe('RampsService', () => { const { service } = getService(); const providersPromise = service.getProviders('us'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(providersPromise).rejects.toThrow( @@ -1451,7 +1447,7 @@ describe('RampsService', () => { const { service } = getService(); const providersPromise = service.getProviders('us'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(providersPromise).rejects.toThrow( @@ -1471,11 +1467,11 @@ describe('RampsService', () => { .reply(500, 'Internal Server Error'); const { service } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const providersPromise = service.getProviders('us'); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(providersPromise).rejects.toThrow( @@ -1537,7 +1533,7 @@ describe('RampsService', () => { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const paymentMethodsResponse = await paymentMethodsPromise; @@ -1572,7 +1568,7 @@ describe('RampsService', () => { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const paymentMethodsResponse = await paymentMethodsPromise; @@ -1600,7 +1596,7 @@ describe('RampsService', () => { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(paymentMethodsPromise).rejects.toThrow( @@ -1629,7 +1625,7 @@ describe('RampsService', () => { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(paymentMethodsPromise).rejects.toThrow( @@ -1658,7 +1654,7 @@ describe('RampsService', () => { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(paymentMethodsPromise).rejects.toThrow( @@ -1682,7 +1678,7 @@ describe('RampsService', () => { .reply(500, 'Internal Server Error'); const { service } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const paymentMethodsPromise = service.getPaymentMethods({ @@ -1691,7 +1687,7 @@ describe('RampsService', () => { assetId: 'eip155:1/slip44:60', provider: '/providers/stripe', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(paymentMethodsPromise).rejects.toThrow( @@ -1710,7 +1706,6 @@ describe('RampsService', () => { amountOut: '0.05', paymentMethod: '/payments/debit-credit-card', amountOutInFiat: 98, - widgetUrl: 'https://buy.moonpay.com/widget?txId=123', }, metadata: { reliability: 95, @@ -1777,7 +1772,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; @@ -1813,7 +1808,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; @@ -1849,7 +1844,7 @@ describe('RampsService', () => { '/payments/bank-transfer', ], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; @@ -1882,9 +1877,44 @@ describe('RampsService', () => { amount: 100, walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], - provider: '/providers/moonpay', + providers: ['/providers/moonpay'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); + await flushPromises(); + const quotesResponse = await quotesPromise; + + expect(quotesResponse.success).toHaveLength(2); + }); + + it('includes multiple provider filters when specified', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/v2/quotes') + .query({ + action: 'buy', + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + region: 'us', + fiat: 'usd', + crypto: 'eip155:1/slip44:60', + amount: '100', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + payments: '/payments/debit-credit-card', + providers: ['/providers/moonpay', '/providers/transak'], + }) + .reply(200, mockQuotesResponse); + const { service } = getService(); + + const quotesPromise = service.getQuotes({ + region: 'us', + fiat: 'usd', + assetId: 'eip155:1/slip44:60', + amount: 100, + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + paymentMethods: ['/payments/debit-credit-card'], + providers: ['/providers/moonpay', '/providers/transak'], + }); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; @@ -1919,7 +1949,7 @@ describe('RampsService', () => { paymentMethods: ['/payments/debit-credit-card'], redirectUrl: 'https://example.com/callback', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; @@ -1953,7 +1983,7 @@ describe('RampsService', () => { paymentMethods: ['/payments/bank-transfer'], action: 'sell', }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; @@ -1986,7 +2016,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow( @@ -2020,7 +2050,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow( @@ -2054,7 +2084,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow( @@ -2093,7 +2123,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow( @@ -2132,7 +2162,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow( @@ -2171,7 +2201,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow( @@ -2198,7 +2228,7 @@ describe('RampsService', () => { .reply(500, 'Internal Server Error'); const { service } = getService(); service.onRetry(() => { - clock.nextAsync().catch(() => undefined); + jest.advanceTimersToNextTimerAsync().catch(() => undefined); }); const quotesPromise = service.getQuotes({ @@ -2209,7 +2239,7 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); await expect(quotesPromise).rejects.toThrow("failed with status '500'"); @@ -2243,13 +2273,97 @@ describe('RampsService', () => { walletAddress: '0x1234567890abcdef1234567890abcdef12345678', paymentMethods: ['/payments/debit-credit-card'], }); - await clock.runAllAsync(); + await jest.runAllTimersAsync(); await flushPromises(); const quotesResponse = await quotesPromise; expect(quotesResponse.success).toHaveLength(2); }); }); + + describe('RampsService:getBuyWidgetUrl', () => { + it('returns buy widget data from the buy URL endpoint', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/providers/transak-staging/buy-widget') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, { + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER', + orderId: null, + }); + const { rootMessenger } = getService(); + + const buyWidgetPromise = rootMessenger.call( + 'RampsService:getBuyWidgetUrl', + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const buyWidget = await buyWidgetPromise; + + expect(buyWidget).toStrictEqual({ + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER', + orderId: null, + }); + }); + + it('throws when the response is not ok', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/providers/transak-staging/buy-widget') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .times(4) + .reply(500, 'Internal Server Error'); + const { service } = getService(); + service.onRetry(() => { + jest.advanceTimersToNextTimerAsync().catch(() => undefined); + }); + + const buyWidgetPromise = service.getBuyWidgetUrl( + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(buyWidgetPromise).rejects.toThrow( + `Fetching 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget?sdk=2.1.6&controller=${CONTROLLER_VERSION}&context=mobile-ios' failed with status '500'`, + ); + }); + + it('throws when the response does not contain url field', async () => { + nock('https://on-ramp.uat-api.cx.metamask.io') + .get('/providers/transak-staging/buy-widget') + .query({ + sdk: '2.1.6', + controller: CONTROLLER_VERSION, + context: 'mobile-ios', + }) + .reply(200, { + browser: 'APP_BROWSER', + orderId: null, + }); + const { rootMessenger } = getService(); + + const buyWidgetPromise = rootMessenger.call( + 'RampsService:getBuyWidgetUrl', + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(buyWidgetPromise).rejects.toThrow( + 'Malformed response received from buy widget URL API', + ); + }); + }); }); /** diff --git a/packages/ramps-controller/src/RampsService.ts b/packages/ramps-controller/src/RampsService.ts index da5a2bcf359..1f85f03aa89 100644 --- a/packages/ramps-controller/src/RampsService.ts +++ b/packages/ramps-controller/src/RampsService.ts @@ -75,6 +75,11 @@ export type ProviderLogos = { width: number; }; +/** + * Browser type for provider buy features. + */ +export type ProviderBrowserType = 'APP_BROWSER' | 'IN_APP_OS_BROWSER' | null; + /** * Represents a ramp provider. */ @@ -127,6 +132,10 @@ export type PaymentMethod = { * Localized pending order description (optional). */ pendingOrderDescription?: string; + /** + * Whether this payment method is a manual bank transfer. + */ + isManualBankTransfer?: boolean; }; /** @@ -171,6 +180,24 @@ export type QuoteCryptoTranslation = { chainId?: string; }; +/** + * Widget information for executing a buy order. + */ +export type BuyWidget = { + /** + * The widget URL to open for the user to complete the purchase. + */ + url: string; + /** + * The browser type to use for opening the widget. + */ + browser?: ProviderBrowserType; + /** + * Order ID if already created. + */ + orderId?: string | null; +}; + /** * Represents an individual quote from a provider. */ @@ -199,10 +226,6 @@ export type Quote = { * The fiat value of the output amount (for buy actions). */ amountOutInFiat?: number; - /** - * The widget URL for redirect providers. - */ - widgetUrl?: string; /** * Crypto translation info for display. */ @@ -219,6 +242,11 @@ export type Quote = { * Provider fees. */ providerFee?: number | string; + /** + * Buy URL endpoint that returns the actual provider widget URL. + * This is a MetaMask-hosted endpoint that, when fetched, returns JSON with the provider's widget URL. + */ + buyURL?: string; }; /** * Metadata about the quote. @@ -350,9 +378,9 @@ export type GetQuotesParams = { */ redirectUrl?: string; /** - * Optional provider ID to filter quotes. + * Optional provider IDs to filter quotes. */ - provider?: string; + providers?: string[]; /** * The ramp action type. Defaults to 'buy'. */ @@ -504,6 +532,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getProviders', 'getPaymentMethods', 'getQuotes', + 'getBuyWidgetUrl', ] as const; /** @@ -1072,7 +1101,7 @@ export class RampsService { * @param params.amount - The amount (in fiat for buy, crypto for sell). * @param params.walletAddress - The destination wallet address. * @param params.redirectUrl - Optional redirect URL after order completion. - * @param params.provider - Optional provider ID to filter quotes. + * @param params.providers - Optional provider IDs to filter quotes. * @param params.action - The ramp action type. Defaults to 'buy'. * @returns The quotes response containing success, sorted, error, and customActions. */ @@ -1100,9 +1129,9 @@ export class RampsService { }); // Add provider filter if specified - if (params.provider) { - url.searchParams.append('providers', params.provider); - } + params.providers?.forEach((provider) => { + url.searchParams.append('providers', provider); + }); // Add redirect URL if specified if (params.redirectUrl) { @@ -1135,4 +1164,34 @@ export class RampsService { return response; } + + /** + * Fetches the buy widget data from a buy URL endpoint. + * Makes a request to the buyURL (as provided in a quote) to get the actual + * provider widget URL, browser type, and order ID. + * + * @param buyUrl - The full buy URL endpoint to fetch from. + * @returns The buy widget data containing the provider widget URL. + */ + async getBuyWidgetUrl(buyUrl: string): Promise { + const url = new URL(buyUrl); + this.#addCommonParams(url); + + const response = await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; + }); + + if (!response || typeof response !== 'object' || !response.url) { + throw new Error('Malformed response received from buy widget URL API'); + } + + return response; + } } diff --git a/packages/ramps-controller/src/RequestCache.test.ts b/packages/ramps-controller/src/RequestCache.test.ts index 2a8e4fc45b9..3319d27220d 100644 --- a/packages/ramps-controller/src/RequestCache.test.ts +++ b/packages/ramps-controller/src/RequestCache.test.ts @@ -44,6 +44,17 @@ describe('RequestCache', () => { }); describe('isCacheExpired', () => { + it('returns true for idle state', () => { + const state = { + status: RequestStatus.IDLE, + data: null, + error: null, + timestamp: Date.now(), + lastFetchedAt: Date.now(), + }; + expect(isCacheExpired(state)).toBe(true); + }); + it('returns true for loading state', () => { const state = createLoadingState(); expect(isCacheExpired(state)).toBe(true); diff --git a/packages/ramps-controller/src/RequestCache.ts b/packages/ramps-controller/src/RequestCache.ts index 08b1c1bcaf7..6f92ff2ee93 100644 --- a/packages/ramps-controller/src/RequestCache.ts +++ b/packages/ramps-controller/src/RequestCache.ts @@ -160,4 +160,6 @@ export type ExecuteRequestOptions = { export type PendingRequest = { promise: Promise; abortController: AbortController; + /** When set, used to abort other in-flight requests for this resource when a new one starts. */ + resourceType?: ResourceType; }; diff --git a/packages/ramps-controller/src/TransakService-method-action-types.ts b/packages/ramps-controller/src/TransakService-method-action-types.ts new file mode 100644 index 00000000000..78a3e64d410 --- /dev/null +++ b/packages/ramps-controller/src/TransakService-method-action-types.ts @@ -0,0 +1,155 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { TransakService } from './TransakService'; + +export type TransakServiceSetApiKeyAction = { + type: `TransakService:setApiKey`; + handler: TransakService['setApiKey']; +}; + +export type TransakServiceSetAccessTokenAction = { + type: `TransakService:setAccessToken`; + handler: TransakService['setAccessToken']; +}; + +export type TransakServiceClearAccessTokenAction = { + type: `TransakService:clearAccessToken`; + handler: TransakService['clearAccessToken']; +}; + +export type TransakServiceSendUserOtpAction = { + type: `TransakService:sendUserOtp`; + handler: TransakService['sendUserOtp']; +}; + +export type TransakServiceVerifyUserOtpAction = { + type: `TransakService:verifyUserOtp`; + handler: TransakService['verifyUserOtp']; +}; + +export type TransakServiceLogoutAction = { + type: `TransakService:logout`; + handler: TransakService['logout']; +}; + +export type TransakServiceGetUserDetailsAction = { + type: `TransakService:getUserDetails`; + handler: TransakService['getUserDetails']; +}; + +export type TransakServiceGetBuyQuoteAction = { + type: `TransakService:getBuyQuote`; + handler: TransakService['getBuyQuote']; +}; + +export type TransakServiceGetKycRequirementAction = { + type: `TransakService:getKycRequirement`; + handler: TransakService['getKycRequirement']; +}; + +export type TransakServiceGetAdditionalRequirementsAction = { + type: `TransakService:getAdditionalRequirements`; + handler: TransakService['getAdditionalRequirements']; +}; + +export type TransakServiceCreateOrderAction = { + type: `TransakService:createOrder`; + handler: TransakService['createOrder']; +}; + +export type TransakServiceGetOrderAction = { + type: `TransakService:getOrder`; + handler: TransakService['getOrder']; +}; + +export type TransakServiceGetUserLimitsAction = { + type: `TransakService:getUserLimits`; + handler: TransakService['getUserLimits']; +}; + +export type TransakServiceRequestOttAction = { + type: `TransakService:requestOtt`; + handler: TransakService['requestOtt']; +}; + +export type TransakServiceGeneratePaymentWidgetUrlAction = { + type: `TransakService:generatePaymentWidgetUrl`; + handler: TransakService['generatePaymentWidgetUrl']; +}; + +export type TransakServiceSubmitPurposeOfUsageFormAction = { + type: `TransakService:submitPurposeOfUsageForm`; + handler: TransakService['submitPurposeOfUsageForm']; +}; + +export type TransakServicePatchUserAction = { + type: `TransakService:patchUser`; + handler: TransakService['patchUser']; +}; + +export type TransakServiceSubmitSsnDetailsAction = { + type: `TransakService:submitSsnDetails`; + handler: TransakService['submitSsnDetails']; +}; + +export type TransakServiceConfirmPaymentAction = { + type: `TransakService:confirmPayment`; + handler: TransakService['confirmPayment']; +}; + +export type TransakServiceGetTranslationAction = { + type: `TransakService:getTranslation`; + handler: TransakService['getTranslation']; +}; + +export type TransakServiceGetIdProofStatusAction = { + type: `TransakService:getIdProofStatus`; + handler: TransakService['getIdProofStatus']; +}; + +export type TransakServiceCancelOrderAction = { + type: `TransakService:cancelOrder`; + handler: TransakService['cancelOrder']; +}; + +export type TransakServiceCancelAllActiveOrdersAction = { + type: `TransakService:cancelAllActiveOrders`; + handler: TransakService['cancelAllActiveOrders']; +}; + +export type TransakServiceGetActiveOrdersAction = { + type: `TransakService:getActiveOrders`; + handler: TransakService['getActiveOrders']; +}; + +/** + * Union of all TransakService action types. + */ +export type TransakServiceMethodActions = + | TransakServiceSetApiKeyAction + | TransakServiceSetAccessTokenAction + | TransakServiceClearAccessTokenAction + | TransakServiceSendUserOtpAction + | TransakServiceVerifyUserOtpAction + | TransakServiceLogoutAction + | TransakServiceGetUserDetailsAction + | TransakServiceGetBuyQuoteAction + | TransakServiceGetKycRequirementAction + | TransakServiceGetAdditionalRequirementsAction + | TransakServiceCreateOrderAction + | TransakServiceGetOrderAction + | TransakServiceGetUserLimitsAction + | TransakServiceRequestOttAction + | TransakServiceGeneratePaymentWidgetUrlAction + | TransakServiceSubmitPurposeOfUsageFormAction + | TransakServicePatchUserAction + | TransakServiceSubmitSsnDetailsAction + | TransakServiceConfirmPaymentAction + | TransakServiceGetTranslationAction + | TransakServiceGetIdProofStatusAction + | TransakServiceCancelOrderAction + | TransakServiceCancelAllActiveOrdersAction + | TransakServiceGetActiveOrdersAction; diff --git a/packages/ramps-controller/src/TransakService.test.ts b/packages/ramps-controller/src/TransakService.test.ts new file mode 100644 index 00000000000..35c86f84ee4 --- /dev/null +++ b/packages/ramps-controller/src/TransakService.test.ts @@ -0,0 +1,2154 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll, isDone } from 'nock'; + +import type { + TransakServiceMessenger, + TransakAccessToken, +} from './TransakService'; +import { + TransakService, + TransakEnvironment, + TransakOrderIdTransformer, +} from './TransakService'; +import { flushPromises } from '../../../tests/helpers'; + +// === Test Constants === + +const MOCK_API_KEY = 'test-api-key-123'; +const MOCK_CONTEXT = 'mobile-ios'; + +const STAGING_TRANSAK_BASE = 'https://api-gateway-stg.transak.com'; +const PRODUCTION_TRANSAK_BASE = 'https://api-gateway.transak.com'; +const STAGING_ORDERS_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; +const PRODUCTION_ORDERS_BASE = 'https://on-ramp.api.cx.metamask.io'; +const STAGING_WIDGET_BASE = 'https://global-stg.transak.com'; + +const STAGING_PROVIDER_PATH = '/providers/transak-native-staging'; +const PRODUCTION_PROVIDER_PATH = '/providers/transak-native'; + +const MOCK_ACCESS_TOKEN: TransakAccessToken = { + accessToken: 'mock-jwt-token-abc', + ttl: 3600, + created: new Date('2025-01-01T00:00:00.000Z'), +}; + +const MOCK_USER_DETAILS = { + id: 'user-123', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + mobileNumber: '+15551234567', + status: 'ACTIVE', + dob: '1990-01-01', + kyc: { + status: 'APPROVED', + type: 'L2', + attempts: [], + highestApprovedKYCType: 'L2', + kycMarkedBy: null, + kycResult: null, + rejectionDetails: null, + userId: 'user-123', + workFlowRunId: 'wfr-123', + }, + address: { + addressLine1: '123 Main St', + addressLine2: '', + state: 'CA', + city: 'San Francisco', + postCode: '94102', + country: 'United States', + countryCode: 'US', + }, + createdAt: '2025-01-01T00:00:00.000Z', +}; + +const MOCK_BUY_QUOTE = { + quoteId: 'quote-123', + conversionPrice: 2500, + marketConversionPrice: 2500, + slippage: 0.5, + fiatCurrency: 'USD', + cryptoCurrency: 'ETH', + paymentMethod: 'credit_debit_card', + fiatAmount: 100, + cryptoAmount: 0.04, + isBuyOrSell: 'BUY', + network: 'ethereum', + feeDecimal: 0.05, + totalFee: 5, + feeBreakdown: [], + nonce: 1, + cryptoLiquidityProvider: 'moonpay', + notes: [], +}; + +const MOCK_TRANSLATION = { + region: 'US', + paymentMethod: 'credit_debit_card', + cryptoCurrency: 'ETH', + network: 'ethereum', + fiatCurrency: 'USD', +}; + +const MOCK_KYC_REQUIREMENT = { + status: 'APPROVED' as const, + kycType: 'L2', + isAllowedToPlaceOrder: true, +}; + +const MOCK_ADDITIONAL_REQUIREMENTS = { + formsRequired: [ + { + type: 'id-proof', + metadata: { + options: ['passport', 'drivers_license'], + documentProofOptions: [], + expiresAt: '2025-12-31T00:00:00.000Z', + kycUrl: 'https://example.com/kyc', + workFlowRunId: 'wfr-456', + }, + }, + ], +}; + +const MOCK_TRANSAK_ORDER = { + orderId: 'order-abc-123', + partnerUserId: 'partner-user-1', + status: 'AWAITING_PAYMENT_FROM_USER', + isBuyOrSell: 'BUY', + fiatCurrency: 'USD', + cryptoCurrency: 'ETH', + network: 'ethereum', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + quoteId: 'quote-123', + fiatAmount: 100, + fiatAmountInUsd: 100, + amountPaid: 0, + cryptoAmount: 0.04, + conversionPrice: 2500, + totalFeeInFiat: 5, + paymentDetails: [ + { + fiatCurrency: 'USD', + paymentMethod: 'credit_debit_card', + fields: [{ name: 'cardNumber', id: 'card', value: '****1234' }], + }, + ], + txHash: '', + transationLink: null, + createdAt: '2025-01-01T00:00:00.000Z', + updatedAt: '2025-01-01T00:00:00.000Z', + completedAt: '', +}; + +const MOCK_DEPOSIT_ORDER = { + id: `${STAGING_PROVIDER_PATH}/orders/order-abc-123`, + provider: 'transak-native-staging', + cryptoAmount: 0.04, + fiatAmount: 100, + cryptoCurrency: { + assetId: 'eip155:1/slip44:60', + name: 'Ethereum', + chainId: 'eip155:1', + decimals: 18, + iconUrl: 'https://example.com/eth.png', + symbol: 'ETH', + }, + fiatCurrency: 'USD', + providerOrderId: 'order-abc-123', + providerOrderLink: '', + createdAt: 1704067200000, + paymentMethod: { + id: 'credit_debit_card', + name: 'Credit/Debit Card', + duration: '5-10 min', + icon: 'card', + }, + totalFeesFiat: 5, + txHash: '', + walletAddress: '0x1234567890abcdef1234567890abcdef12345678', + status: 'AWAITING_PAYMENT_FROM_USER', + network: { name: 'Ethereum', chainId: 'eip155:1' }, + timeDescriptionPending: '5-10 min', + fiatAmountInUsd: 100, + feesInUsd: 5, + region: { + isoCode: 'US', + flag: '🇺🇸', + name: 'United States', + phone: { + prefix: '+1', + placeholder: '(555) 123-4567', + template: '(###) ###-####', + }, + currency: 'USD', + supported: true, + }, + orderType: 'DEPOSIT' as const, + paymentDetails: [], +}; + +const MOCK_USER_LIMITS = { + limits: { '1': 5000, '30': 25000, '365': 100000 }, + spent: { '1': 100, '30': 500, '365': 2000 }, + remaining: { '1': 4900, '30': 24500, '365': 98000 }, + exceeded: { '1': false, '30': false, '365': false }, + shortage: {}, +}; + +// === Test Setup Helpers === + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getMessenger(rootMessenger: RootMessenger): TransakServiceMessenger { + return new Messenger({ + namespace: 'TransakService', + parent: rootMessenger, + }); +} + +function getService({ + options = {}, +}: { + options?: Partial[0]>; +} = {}): { + service: TransakService; + rootMessenger: RootMessenger; + messenger: TransakServiceMessenger; +} { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new TransakService({ + fetch, + messenger, + context: MOCK_CONTEXT, + apiKey: MOCK_API_KEY, + environment: TransakEnvironment.Staging, + policyOptions: { maxRetries: 0 }, + ...options, + }); + + return { service, rootMessenger, messenger }; +} + +function authenticateService(service: TransakService): void { + service.setAccessToken(MOCK_ACCESS_TOKEN); +} + +/** + * Sets up a nock interceptor for the staging Transak translation endpoint. + * Many methods call getTranslation internally, so this helper avoids repetition. + * + * @param translationResponse - The mock translation response to return. + * @param queryOverrides - Optional query parameter overrides to match against. + * @returns The nock interceptor. + */ +function nockTranslation( + translationResponse = MOCK_TRANSLATION, + queryOverrides?: Record, +): nock.Interceptor { + return nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query((query) => { + if (!query.action || !query.context) { + return false; + } + if (queryOverrides) { + return Object.entries(queryOverrides).every( + ([key, value]) => query[key] === value, + ); + } + return true; + }) + .reply(200, translationResponse); +} + +// === Tests === + +describe('TransakService', () => { + beforeEach(() => { + jest.useFakeTimers({ now: 0, doNotFake: ['nextTick', 'queueMicrotask'] }); + }); + + afterEach(() => { + jest.useRealTimers(); + cleanAll(); + }); + + describe('constructor', () => { + it('creates a service with the correct name', () => { + const { service } = getService(); + expect(service.name).toBe('TransakService'); + }); + + it('registers messenger action handlers', () => { + const { rootMessenger, service } = getService(); + service.setApiKey('new-key'); + expect(service.getApiKey()).toBe('new-key'); + + rootMessenger.call('TransakService:setApiKey', 'messenger-key'); + expect(service.getApiKey()).toBe('messenger-key'); + }); + + it('defaults to staging environment', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { data: [] }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + await promise; + expect(isDone()).toBe(true); + }); + + it('uses production URLs when environment is Production', async () => { + nock(PRODUCTION_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { data: [] }); + + const { service } = getService({ + options: { environment: TransakEnvironment.Production }, + }); + authenticateService(service); + + const promise = service.getActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + await promise; + expect(isDone()).toBe(true); + }); + + it('stores the initial API key when provided', () => { + const { service } = getService({ options: { apiKey: 'initial-key' } }); + expect(service.getApiKey()).toBe('initial-key'); + }); + + it('uses default environment and policyOptions when not provided', () => { + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const service = new TransakService({ + fetch, + messenger, + context: MOCK_CONTEXT, + apiKey: MOCK_API_KEY, + }); + expect(service.name).toBe('TransakService'); + }); + + it('throws for invalid environment when resolving Transak API base URL', async () => { + const { service } = getService({ + options: { environment: 'invalid' as TransakEnvironment }, + }); + authenticateService(service); + + await expect(service.getUserDetails()).rejects.toThrow( + 'Invalid Transak environment: invalid', + ); + }); + + it('throws for invalid environment when resolving Ramps base URL', async () => { + const { service } = getService({ + options: { environment: 'invalid' as TransakEnvironment }, + }); + + await expect( + service.getOrder('raw-order-id', '0xWALLET'), + ).rejects.toThrow('Invalid Transak environment: invalid'); + }); + + it('throws for invalid environment when resolving payment widget base URL', () => { + const { service } = getService({ + options: { environment: 'invalid' as TransakEnvironment }, + }); + + expect(() => + service.generatePaymentWidgetUrl('ott', MOCK_BUY_QUOTE, '0x1'), + ).toThrow('Invalid Transak environment: invalid'); + }); + }); + + describe('API key management', () => { + it('setApiKey updates the stored API key', () => { + const { service } = getService(); + service.setApiKey('new-api-key'); + expect(service.getApiKey()).toBe('new-api-key'); + }); + + it('getApiKey returns null when no key is set', () => { + const { service } = getService({ options: { apiKey: undefined } }); + expect(service.getApiKey()).toBeNull(); + }); + + it('throws when making a request without an API key', async () => { + const { service } = getService({ options: { apiKey: undefined } }); + authenticateService(service); + + await expect(service.getUserDetails()).rejects.toThrow( + 'Transak API key is required but not set.', + ); + }); + }); + + describe('access token management', () => { + it('setAccessToken stores the token', () => { + const { service } = getService(); + service.setAccessToken(MOCK_ACCESS_TOKEN); + expect(service.getAccessToken()).toStrictEqual(MOCK_ACCESS_TOKEN); + }); + + it('getAccessToken returns null before any token is set', () => { + const { service } = getService(); + expect(service.getAccessToken()).toBeNull(); + }); + + it('clearAccessToken removes the stored token', () => { + const { service } = getService(); + service.setAccessToken(MOCK_ACCESS_TOKEN); + service.clearAccessToken(); + expect(service.getAccessToken()).toBeNull(); + }); + + it('clearAccessToken via messenger', () => { + const { service, rootMessenger } = getService(); + service.setAccessToken(MOCK_ACCESS_TOKEN); + rootMessenger.call('TransakService:clearAccessToken'); + expect(service.getAccessToken()).toBeNull(); + }); + + it('throws 401 HttpError when calling authenticated endpoint without token', async () => { + const { service } = getService(); + + await expect(service.getUserDetails()).rejects.toThrow( + 'Authentication required. Please log in to continue.', + ); + }); + + it('throws 401 HttpError and clears token when token has expired', async () => { + const { service } = getService(); + const expiredToken: TransakAccessToken = { + accessToken: 'expired-jwt', + ttl: 3600, + created: new Date(Date.now() - 3601 * 1000), + }; + service.setAccessToken(expiredToken); + + await expect(service.getUserDetails()).rejects.toThrow( + 'Authentication token has expired. Please log in again.', + ); + expect(service.getAccessToken()).toBeNull(); + }); + + it('handles created field as a string (e.g. after messenger serialization)', async () => { + const { service } = getService(); + const tokenWithStringDate = { + accessToken: 'jwt-from-messenger', + ttl: 3600, + created: new Date(Date.now() - 3601 * 1000).toISOString(), + } as unknown as TransakAccessToken; + service.setAccessToken(tokenWithStringDate); + + await expect(service.getUserDetails()).rejects.toThrow( + 'Authentication token has expired. Please log in again.', + ); + expect(service.getAccessToken()).toBeNull(); + }); + + it('allows requests when token is within TTL', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/user/') + .query(true) + .reply(200, { data: MOCK_USER_DETAILS }); + + const { service } = getService(); + const validToken: TransakAccessToken = { + accessToken: 'valid-jwt', + ttl: 3600, + created: new Date(Date.now() - 1800 * 1000), + }; + service.setAccessToken(validToken); + + const promise = service.getUserDetails(); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual(MOCK_USER_DETAILS); + }); + }); + + describe('sendUserOtp', () => { + it('sends a POST to /api/v2/auth/login with the email', async () => { + const mockResponse = { + isTncAccepted: true, + stateToken: 'state-token-123', + email: 'test@example.com', + expiresIn: 300, + }; + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/auth/login', + (body) => + body.email === 'test@example.com' && body.apiKey === MOCK_API_KEY, + ) + .reply(200, { data: mockResponse }); + + const { service } = getService(); + + const promise = service.sendUserOtp('test@example.com'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(mockResponse); + }); + + it('sends OTP via messenger', async () => { + const mockResponse = { + isTncAccepted: false, + stateToken: 'state-abc', + email: 'user@test.com', + expiresIn: 600, + }; + + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/auth/login', (body) => body.email === 'user@test.com') + .reply(200, { data: mockResponse }); + + const { rootMessenger } = getService(); + + const promise = rootMessenger.call( + 'TransakService:sendUserOtp', + 'user@test.com', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(mockResponse); + }); + + it('throws when the API responds with an error', async () => { + nock(STAGING_TRANSAK_BASE).post('/api/v2/auth/login').reply(400); + + const { service } = getService(); + + const promise = service.sendUserOtp('invalid'); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '400'"); + }); + }); + + describe('verifyUserOtp', () => { + it('verifies OTP and returns an access token', async () => { + const mockApiResponse = { + accessToken: 'jwt-token-verified', + ttl: 7200, + created: '2025-06-01T12:00:00.000Z', + }; + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/auth/verify', + (body) => + body.email === 'test@example.com' && + body.otp === '123456' && + body.stateToken === 'state-token' && + body.apiKey === MOCK_API_KEY, + ) + .reply(200, { data: mockApiResponse }); + + const { service } = getService(); + + const promise = service.verifyUserOtp( + 'test@example.com', + '123456', + 'state-token', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual({ + accessToken: 'jwt-token-verified', + ttl: 7200, + created: new Date('2025-06-01T12:00:00.000Z'), + }); + }); + + it('sets the access token on the service after verification', async () => { + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/auth/verify') + .reply(200, { + data: { + accessToken: 'auto-stored-token', + ttl: 3600, + created: '2025-01-01T00:00:00.000Z', + }, + }); + + const { service } = getService(); + expect(service.getAccessToken()).toBeNull(); + + const promise = service.verifyUserOtp('a@b.com', '000000', 'st'); + await jest.runAllTimersAsync(); + await flushPromises(); + await promise; + + expect(service.getAccessToken()?.accessToken).toBe('auto-stored-token'); + }); + + it('throws when verification fails', async () => { + nock(STAGING_TRANSAK_BASE).post('/api/v2/auth/verify').reply(401); + + const { service } = getService(); + + const promise = service.verifyUserOtp('a@b.com', 'wrong', 'st'); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '401'"); + }); + }); + + describe('logout', () => { + it('posts to the logout endpoint and clears the token', async () => { + nock(STAGING_TRANSAK_BASE) + .post('/api/v1/auth/logout') + .reply(200, { data: 'success' }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.logout(); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toBe('success'); + expect(service.getAccessToken()).toBeNull(); + }); + + it('clears the token and returns a message when already logged out (401)', async () => { + nock(STAGING_TRANSAK_BASE).post('/api/v1/auth/logout').reply(401); + + const { service } = getService(); + authenticateService(service); + + const promise = service.logout(); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toBe('user was already logged out'); + expect(service.getAccessToken()).toBeNull(); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.logout()).rejects.toThrow('Authentication required'); + }); + + it('rethrows non-401 errors without clearing the token', async () => { + nock(STAGING_TRANSAK_BASE).post('/api/v1/auth/logout').reply(500); + + const { service } = getService(); + authenticateService(service); + + const promise = service.logout(); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '500'"); + expect(service.getAccessToken()).not.toBeNull(); + }); + }); + + describe('getUserDetails', () => { + it('returns user details from the API', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/user/') + .query((query) => query.apiKey === MOCK_API_KEY) + .reply(200, { data: MOCK_USER_DETAILS }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getUserDetails(); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(MOCK_USER_DETAILS); + }); + + it('sends the authorization header', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/user/') + .query(true) + .matchHeader('authorization', MOCK_ACCESS_TOKEN.accessToken) + .reply(200, { data: MOCK_USER_DETAILS }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getUserDetails(); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.getUserDetails()).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('patchUser', () => { + it('sends a PATCH request with personal details', async () => { + const patchData = { + personalDetails: { + firstName: 'Updated', + lastName: 'Name', + }, + }; + + nock(STAGING_TRANSAK_BASE) + .patch( + '/api/v2/kyc/user', + (body) => body.personalDetails?.firstName === 'Updated', + ) + .query((query) => query.apiKey === MOCK_API_KEY) + .reply(200, { data: { success: true } }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.patchUser(patchData); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual({ success: true }); + }); + + it('sends a PATCH request with address details', async () => { + const patchData = { + addressDetails: { + addressLine1: '456 Oak Ave', + city: 'New York', + state: 'NY', + postCode: '10001', + countryCode: 'US', + }, + }; + + nock(STAGING_TRANSAK_BASE) + .patch( + '/api/v2/kyc/user', + (body) => body.addressDetails?.city === 'New York', + ) + .query(true) + .reply(200, { data: { success: true } }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.patchUser(patchData); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual({ success: true }); + }); + + it('throws when the PATCH API returns a non-OK response', async () => { + nock(STAGING_TRANSAK_BASE) + .patch('/api/v2/kyc/user') + .query(true) + .reply(500); + + const { service } = getService(); + authenticateService(service); + + const promise = service.patchUser({ + personalDetails: { firstName: 'Fail' }, + }); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '500'"); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect( + service.patchUser({ personalDetails: { firstName: 'X' } }), + ).rejects.toThrow('Authentication required'); + }); + }); + + describe('getBuyQuote', () => { + it('translates parameters and fetches a quote', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/lookup/quotes') + .query( + (query) => + query.fiatCurrency === 'USD' && + query.cryptoCurrency === 'ETH' && + query.network === 'ethereum' && + query.paymentMethod === 'credit_debit_card' && + query.fiatAmount === '100' && + query.isBuyOrSell === 'BUY' && + query.isFeeExcludedFromFiat === 'true' && + query.apiKey === MOCK_API_KEY, + ) + .reply(200, { data: MOCK_BUY_QUOTE }); + + const { service } = getService(); + + const promise = service.getBuyQuote( + 'USD', + 'eip155:1/slip44:60', + 'eip155:1', + 'credit_debit_card', + '100', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(MOCK_BUY_QUOTE); + }); + + it('normalizes ramps API payment method IDs before translation', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query((query) => query.paymentMethod === 'credit_debit_card') + .reply(200, MOCK_TRANSLATION); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/lookup/quotes') + .query(true) + .reply(200, { data: MOCK_BUY_QUOTE }); + + const { service } = getService(); + + const promise = service.getBuyQuote( + 'USD', + 'eip155:1/slip44:60', + 'eip155:1', + '/payments/debit-credit-card', + '100', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + + it('omits paymentMethod param when translation returns undefined', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query(true) + .reply(200, { + ...MOCK_TRANSLATION, + paymentMethod: undefined, + }); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/lookup/quotes') + .query((query) => !('paymentMethod' in query)) + .reply(200, { data: MOCK_BUY_QUOTE }); + + const { service } = getService(); + + const promise = service.getBuyQuote('USD', 'ETH', 'eip155:1', '', '100'); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + }); + + describe('getTranslation', () => { + it('calls the ramps translation endpoint with query params', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query( + (query) => + query.action === 'deposit' && + query.context === MOCK_CONTEXT && + query.cryptoCurrencyId === 'eip155:1/slip44:60' && + query.chainId === 'eip155:1' && + query.fiatCurrencyId === 'USD', + ) + .reply(200, MOCK_TRANSLATION); + + const { service } = getService(); + + const promise = service.getTranslation({ + cryptoCurrencyId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + fiatCurrencyId: 'USD', + }); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(MOCK_TRANSLATION); + }); + + it('uses production orders URL when environment is Production', async () => { + nock(PRODUCTION_ORDERS_BASE) + .get(`${PRODUCTION_PROVIDER_PATH}/native/translate`) + .query(true) + .reply(200, MOCK_TRANSLATION); + + const { service } = getService({ + options: { environment: TransakEnvironment.Production }, + }); + + const promise = service.getTranslation({ fiatCurrencyId: 'USD' }); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual(MOCK_TRANSLATION); + }); + + it('omits undefined values from query params', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query( + (query) => + query.fiatCurrencyId === 'USD' && + !('paymentMethod' in query) && + !('cryptoCurrencyId' in query), + ) + .reply(200, MOCK_TRANSLATION); + + const { service } = getService(); + + const promise = service.getTranslation({ + fiatCurrencyId: 'USD', + paymentMethod: undefined, + cryptoCurrencyId: undefined, + }); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + + it('normalizes ramps payment method IDs to deposit format', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query( + (query) => + query.paymentMethod === 'credit_debit_card' && + query.fiatCurrencyId === 'USD', + ) + .reply(200, MOCK_TRANSLATION); + + const { service } = getService(); + + const promise = service.getTranslation({ + fiatCurrencyId: 'USD', + paymentMethod: '/payments/debit-credit-card', + }); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual(MOCK_TRANSLATION); + }); + + it('passes through payment methods already in deposit format', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query( + (query) => + query.paymentMethod === 'credit_debit_card' && + query.fiatCurrencyId === 'USD', + ) + .reply(200, MOCK_TRANSLATION); + + const { service } = getService(); + + const promise = service.getTranslation({ + fiatCurrencyId: 'USD', + paymentMethod: 'credit_debit_card', + }); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual(MOCK_TRANSLATION); + }); + + it('throws when translation endpoint fails', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query(true) + .reply(500); + + const { service } = getService(); + + const promise = service.getTranslation({ fiatCurrencyId: 'USD' }); + promise.catch(() => undefined); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '500'"); + }); + }); + + describe('getKycRequirement', () => { + it('fetches KYC requirement for a quote', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/kyc/requirement') + .query( + (query) => + query['metadata[quoteId]'] === 'quote-123' && + query.apiKey === MOCK_API_KEY, + ) + .reply(200, { data: MOCK_KYC_REQUIREMENT }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getKycRequirement('quote-123'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(MOCK_KYC_REQUIREMENT); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.getKycRequirement('quote-123')).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('getAdditionalRequirements', () => { + it('fetches additional requirements for a quote', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/kyc/additional-requirements') + .query((query) => query['metadata[quoteId]'] === 'quote-456') + .reply(200, { data: MOCK_ADDITIONAL_REQUIREMENTS }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getAdditionalRequirements('quote-456'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(MOCK_ADDITIONAL_REQUIREMENTS); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.getAdditionalRequirements('q-1')).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('submitSsnDetails', () => { + it('submits SSN and quoteId', async () => { + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/kyc/ssn', + (body) => body.ssn === '123-45-6789' && body.quoteId === 'quote-123', + ) + .reply(200, { data: { success: true } }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.submitSsnDetails('123-45-6789', 'quote-123'); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual({ success: true }); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect( + service.submitSsnDetails('111-22-3333', 'q1'), + ).rejects.toThrow('Authentication required'); + }); + }); + + describe('submitPurposeOfUsageForm', () => { + it('submits purpose list', async () => { + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/kyc/purpose-of-usage', + (body) => + Array.isArray(body.purposeList) && + body.purposeList.includes('investment'), + ) + .reply(200, { data: null }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.submitPurposeOfUsageForm([ + 'investment', + 'payments', + ]); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeUndefined(); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.submitPurposeOfUsageForm(['test'])).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('getIdProofStatus', () => { + it('fetches ID proof status for a workflow run', async () => { + const mockStatus = { + status: 'SUBMITTED' as const, + kycType: 'L2', + randomLogIdentifier: 'log-123', + }; + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/kyc/id-proof-status') + .query((query) => query.workFlowRunId === 'wfr-123') + .reply(200, { data: mockStatus }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getIdProofStatus('wfr-123'); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual(mockStatus); + }); + }); + + describe('createOrder', () => { + it('creates an order and returns the deposit-formatted order', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/orders', + (body) => + body.quoteId === 'quote-123' && + body.walletAddress === '0x1234' && + body.paymentInstrumentId === 'credit_debit_card', + ) + .reply(200, { data: MOCK_TRANSAK_ORDER }); + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService(); + authenticateService(service); + + const promise = service.createOrder( + 'quote-123', + '0x1234', + 'credit_debit_card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.id).toBe(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`); + expect(result.orderType).toBe('DEPOSIT'); + }); + + it('throws when the order creation API returns an error', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE).post('/api/v2/orders').reply(400); + + const { service } = getService(); + authenticateService(service); + + const promise = service.createOrder( + 'quote-123', + '0x1234', + 'credit_debit_card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '400'"); + }); + + it('normalizes ramps payment method IDs for the translation', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query((query) => query.paymentMethod === 'credit_debit_card') + .reply(200, MOCK_TRANSLATION); + + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/orders') + .reply(200, { data: MOCK_TRANSAK_ORDER }); + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService(); + authenticateService(service); + + const promise = service.createOrder( + 'quote-123', + '0x1234', + '/payments/debit-credit-card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + + it('falls back to the normalized payment method when translation returns undefined', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query(true) + .reply(200, { ...MOCK_TRANSLATION, paymentMethod: undefined }); + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/orders', + (body) => body.paymentInstrumentId === 'credit_debit_card', + ) + .reply(200, { data: MOCK_TRANSAK_ORDER }); + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService(); + authenticateService(service); + + const promise = service.createOrder( + 'quote-123', + '0x1234', + '/payments/debit-credit-card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + + it('retries order creation when the first attempt fails with an existing order error', async () => { + jest.useRealTimers(); + + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/orders') + .once() + .reply(409, { + error: { code: '4005', message: 'Order exists' }, + }); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { data: [] }); + + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/orders') + .reply(200, { data: MOCK_TRANSAK_ORDER }); + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService({ options: { orderRetryDelayMs: 50 } }); + service.setAccessToken({ + ...MOCK_ACCESS_TOKEN, + created: new Date(), + }); + + const result = await service.createOrder( + 'quote-123', + '0x1234', + 'credit_debit_card', + ); + + expect(result.id).toBe(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`); + expect(result.orderType).toBe('DEPOSIT'); + }, 10000); + + it('throws without retrying when a 409 response does not contain the order-exists error code', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/orders') + .once() + .reply(409, { + error: { code: '9999', message: 'Some other conflict' }, + }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.createOrder( + 'quote-123', + '0x1234', + 'credit_debit_card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '409'"); + }); + + it('throws a TransakApiError with the parsed errorCode from the response body', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/orders') + .reply(422, { + error: { code: '5001', message: 'Validation failed' }, + }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.createOrder( + 'quote-123', + '0x1234', + 'credit_debit_card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow( + expect.objectContaining({ + httpStatus: 422, + errorCode: '5001', + }), + ); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.createOrder('q-1', '0x1', 'card')).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('getOrder', () => { + it('fetches an order by deposit order ID', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query( + (query) => + query.wallet === '0x1234' && + query.action === 'deposit' && + query.context === MOCK_CONTEXT, + ) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService(); + + const promise = service.getOrder(depositOrderId, '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.id).toBe(depositOrderId); + expect(result.orderType).toBe('DEPOSIT'); + }); + + it('converts a raw Transak order ID to deposit format', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/raw-order-id`) + .query(true) + .reply(200, { + ...MOCK_DEPOSIT_ORDER, + id: `${STAGING_PROVIDER_PATH}/orders/raw-order-id`, + }); + + const { service } = getService(); + + const promise = service.getOrder('raw-order-id', '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.id).toBe(`${STAGING_PROVIDER_PATH}/orders/raw-order-id`); + }); + + it('uses provided paymentDetails instead of fetching from Transak API', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + const paymentDetails = [ + { + fiatCurrency: 'USD', + paymentMethod: 'card', + fields: [{ name: 'test', id: 'test', value: 'val' }], + }, + ]; + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, MOCK_DEPOSIT_ORDER); + + const { service } = getService(); + + const promise = service.getOrder( + depositOrderId, + '0x1234', + paymentDetails, + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.paymentDetails).toStrictEqual(paymentDetails); + }); + + it('fetches paymentDetails from Transak when authenticated and not provided', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + const orderWithoutPaymentDetails = { + ...MOCK_DEPOSIT_ORDER, + paymentDetails: [], + }; + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, orderWithoutPaymentDetails); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/orders/order-abc-123') + .query(true) + .reply(200, { data: MOCK_TRANSAK_ORDER }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getOrder(depositOrderId, '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.paymentDetails).toStrictEqual( + MOCK_TRANSAK_ORDER.paymentDetails, + ); + }); + + it('returns order without paymentDetails when unauthenticated and not provided', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + const orderWithoutPaymentDetails = { + ...MOCK_DEPOSIT_ORDER, + paymentDetails: [], + }; + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, orderWithoutPaymentDetails); + + const { service } = getService(); + + const promise = service.getOrder(depositOrderId, '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.paymentDetails).toStrictEqual([]); + }); + + it('throws when the orders API returns a non-OK response', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(503); + + const { service } = getService(); + + const promise = service.getOrder(depositOrderId, '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '503'"); + }); + + it('gracefully handles failure when fetching paymentDetails from Transak', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + const orderWithoutPaymentDetails = { + ...MOCK_DEPOSIT_ORDER, + paymentDetails: [], + }; + + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/orders/order-abc-123`) + .query(true) + .reply(200, orderWithoutPaymentDetails); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/orders/order-abc-123') + .query(true) + .reply(500); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getOrder(depositOrderId, '0x1234'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result.paymentDetails).toStrictEqual([]); + }); + }); + + describe('getUserLimits', () => { + it('fetches user limits with translated payment method', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/orders/user-limit') + .query( + (query) => + query.isBuyOrSell === 'BUY' && + query.kycType === 'L2' && + query.fiatCurrency === 'USD' && + query.paymentCategory === 'credit_debit_card', + ) + .reply(200, { data: MOCK_USER_LIMITS }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getUserLimits('USD', 'credit_debit_card', 'L2'); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual(MOCK_USER_LIMITS); + }); + + it('omits paymentCategory when translation returns undefined', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query(true) + .reply(200, { ...MOCK_TRANSLATION, paymentMethod: undefined }); + + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/orders/user-limit') + .query((query) => !('paymentCategory' in query)) + .reply(200, { data: MOCK_USER_LIMITS }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getUserLimits('USD', '', 'L2'); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual(MOCK_USER_LIMITS); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.getUserLimits('USD', 'card', 'L2')).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('requestOtt', () => { + it('requests a one-time token', async () => { + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/auth/request-ott') + .reply(200, { data: { ott: 'ott-token-xyz' } }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.requestOtt(); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual({ ott: 'ott-token-xyz' }); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.requestOtt()).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('generatePaymentWidgetUrl', () => { + it('generates a valid widget URL with all required params', () => { + const { service } = getService(); + + const url = service.generatePaymentWidgetUrl( + 'ott-token', + MOCK_BUY_QUOTE, + '0xWALLET', + ); + + const parsed = new URL(url); + expect(parsed.origin).toBe(STAGING_WIDGET_BASE); + expect(parsed.searchParams.get('apiKey')).toBe(MOCK_API_KEY); + expect(parsed.searchParams.get('ott')).toBe('ott-token'); + expect(parsed.searchParams.get('walletAddress')).toBe('0xWALLET'); + }); + + it('includes extra params when provided', () => { + const { service } = getService(); + + const url = service.generatePaymentWidgetUrl( + 'ott-token', + MOCK_BUY_QUOTE, + '0xWALLET', + { themeColor: '037dd6', customParam: 'value' }, + ); + + const parsed = new URL(url); + expect(parsed.searchParams.get('themeColor')).toBe('037dd6'); + expect(parsed.searchParams.get('customParam')).toBe('value'); + }); + + it('uses the staging widget base URL', () => { + const { service } = getService(); + + const url = service.generatePaymentWidgetUrl( + 'ott', + MOCK_BUY_QUOTE, + '0x1', + ); + + expect(url).toContain(STAGING_WIDGET_BASE); + }); + + it('uses production widget URL when environment is Production', () => { + const { service } = getService({ + options: { environment: TransakEnvironment.Production }, + }); + + const url = service.generatePaymentWidgetUrl( + 'ott', + MOCK_BUY_QUOTE, + '0x1', + ); + + expect(url).toContain('https://global.transak.com'); + }); + + it('logs a warning when quote.paymentMethod is falsy', () => { + const { service } = getService(); + const quoteWithoutPaymentMethod = { + ...MOCK_BUY_QUOTE, + paymentMethod: '', + }; + + const url = service.generatePaymentWidgetUrl( + 'ott-token', + quoteWithoutPaymentMethod, + '0xWALLET', + ); + + const parsed = new URL(url); + expect(parsed.origin).toBe(STAGING_WIDGET_BASE); + expect(parsed.searchParams.get('paymentMethod')).toBe(''); + }); + + it('throws when API key is not set', () => { + const { service } = getService({ options: { apiKey: undefined } }); + + expect(() => + service.generatePaymentWidgetUrl('ott', MOCK_BUY_QUOTE, '0x1'), + ).toThrow('Transak API key is required but not set.'); + }); + + it('includes all expected query parameters', () => { + const { service } = getService(); + + const url = service.generatePaymentWidgetUrl( + 'ott-123', + MOCK_BUY_QUOTE, + '0xABC', + ); + + const parsed = new URL(url); + expect(parsed.searchParams.get('apiKey')).toBe(MOCK_API_KEY); + expect(parsed.searchParams.get('ott')).toBe('ott-123'); + expect(parsed.searchParams.get('fiatCurrency')).toBe('USD'); + expect(parsed.searchParams.get('cryptoCurrencyCode')).toBe('ETH'); + expect(parsed.searchParams.get('productsAvailed')).toBe('BUY'); + expect(parsed.searchParams.get('fiatAmount')).toBe('100'); + expect(parsed.searchParams.get('network')).toBe('ethereum'); + expect(parsed.searchParams.get('hideExchangeScreen')).toBe('true'); + expect(parsed.searchParams.get('walletAddress')).toBe('0xABC'); + expect(parsed.searchParams.get('disableWalletAddressForm')).toBe('true'); + expect(parsed.searchParams.get('paymentMethod')).toBe( + 'credit_debit_card', + ); + expect(parsed.searchParams.get('hideMenu')).toBe('true'); + }); + }); + + describe('confirmPayment', () => { + it('confirms payment with translated payment method', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/orders/payment-confirmation', + (body) => + body.orderId === 'order-abc-123' && + body.paymentMethod === 'credit_debit_card', + ) + .reply(200, { data: { success: true } }); + + const { service } = getService(); + authenticateService(service); + + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-abc-123`; + const promise = service.confirmPayment( + depositOrderId, + 'credit_debit_card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toStrictEqual({ success: true }); + }); + + it('extracts Transak order ID from deposit order ID', async () => { + nockTranslation(); + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/orders/payment-confirmation', + (body) => body.orderId === 'raw-order-id', + ) + .reply(200, { data: { success: true } }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.confirmPayment( + 'raw-order-id', + 'credit_debit_card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual({ success: true }); + }); + + it('falls back to the normalized payment method when translation returns undefined', async () => { + nock(STAGING_ORDERS_BASE) + .get(`${STAGING_PROVIDER_PATH}/native/translate`) + .query(true) + .reply(200, { ...MOCK_TRANSLATION, paymentMethod: undefined }); + + nock(STAGING_TRANSAK_BASE) + .post( + '/api/v2/orders/payment-confirmation', + (body) => body.paymentMethod === 'credit_debit_card', + ) + .reply(200, { data: { success: true } }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.confirmPayment( + 'order-1', + '/payments/debit-credit-card', + ); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual({ success: true }); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.confirmPayment('o-1', 'card')).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('cancelOrder', () => { + it('sends a DELETE request with the Transak order ID', async () => { + const depositOrderId = `${STAGING_PROVIDER_PATH}/orders/order-to-cancel`; + + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/order-to-cancel') + .query( + (query) => + query.cancelReason === 'Creating new order' && + query.apiKey === MOCK_API_KEY, + ) + .reply(200); + + const { service } = getService(); + authenticateService(service); + + const promise = service.cancelOrder(depositOrderId); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeUndefined(); + }); + + it('handles raw Transak order ID', async () => { + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/raw-cancel-id') + .query(true) + .reply(200); + + const { service } = getService(); + authenticateService(service); + + const promise = service.cancelOrder('raw-cancel-id'); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeUndefined(); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.cancelOrder('o-1')).rejects.toThrow( + 'Authentication required', + ); + }); + + it('throws when the API returns an error', async () => { + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/bad-order') + .query(true) + .reply(404); + + const { service } = getService(); + authenticateService(service); + + const promise = service.cancelOrder('bad-order'); + await jest.runAllTimersAsync(); + await flushPromises(); + + await expect(promise).rejects.toThrow("failed with status '404'"); + }); + }); + + describe('cancelAllActiveOrders', () => { + it('fetches active orders and cancels each one, returning empty errors', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { + data: [ + { ...MOCK_TRANSAK_ORDER, orderId: 'active-1' }, + { ...MOCK_TRANSAK_ORDER, orderId: 'active-2' }, + ], + }); + + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/active-1') + .query(true) + .reply(200); + + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/active-2') + .query(true) + .reply(200); + + const { service } = getService(); + authenticateService(service); + + const promise = service.cancelAllActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual([]); + expect(isDone()).toBe(true); + }); + + it('collects individual cancel errors instead of throwing', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { + data: [ + { ...MOCK_TRANSAK_ORDER, orderId: 'fail-order' }, + { ...MOCK_TRANSAK_ORDER, orderId: 'succeed-order' }, + ], + }); + + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/fail-order') + .query(true) + .reply(500); + + nock(STAGING_TRANSAK_BASE) + .delete('/api/v2/orders/succeed-order') + .query(true) + .reply(200); + + const { service } = getService(); + authenticateService(service); + + const promise = service.cancelAllActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + + const errors = await promise; + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toContain("failed with status '500'"); + }); + + it('wraps non-Error throws in Error objects', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { + data: [{ ...MOCK_TRANSAK_ORDER, orderId: 'string-error-order' }], + }); + + const { service } = getService(); + authenticateService(service); + + jest + .spyOn(service, 'cancelOrder') + .mockRejectedValue('string error value'); + + const promise = service.cancelAllActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + + const errors = await promise; + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(Error); + expect(errors[0].message).toBe('string error value'); + }); + + it('returns empty array when there are no active orders', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { data: [] }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.cancelAllActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toStrictEqual([]); + }); + }); + + describe('getActiveOrders', () => { + it('fetches active orders', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query(true) + .reply(200, { data: [MOCK_TRANSAK_ORDER] }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + const result = await promise; + + expect(result).toHaveLength(1); + expect(result[0].orderId).toBe('order-abc-123'); + }); + + it('throws when not authenticated', async () => { + const { service } = getService(); + await expect(service.getActiveOrders()).rejects.toThrow( + 'Authentication required', + ); + }); + }); + + describe('HTTP method handling', () => { + it('does not include null/undefined values in GET query params', async () => { + nock(STAGING_TRANSAK_BASE) + .get('/api/v2/active-orders') + .query((query) => { + const keys = Object.keys(query); + return ( + !keys.includes('undefinedParam') && !keys.includes('nullParam') + ); + }) + .reply(200, { data: [] }); + + const { service } = getService(); + authenticateService(service); + + const promise = service.getActiveOrders(); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + + it('includes Content-Type and Accept headers on POST requests', async () => { + nock(STAGING_TRANSAK_BASE) + .post('/api/v2/auth/login') + .matchHeader('Content-Type', 'application/json') + .matchHeader('Accept', 'application/json') + .reply(200, { + data: { + isTncAccepted: true, + stateToken: 'st', + email: 'a@b.com', + expiresIn: 300, + }, + }); + + const { service } = getService(); + + const promise = service.sendUserOtp('a@b.com'); + await jest.runAllTimersAsync(); + await flushPromises(); + + expect(await promise).toBeDefined(); + }); + }); +}); + +describe('TransakOrderIdTransformer', () => { + describe('depositOrderIdToTransakOrderId', () => { + it('extracts the Transak order ID from a deposit order ID', () => { + expect( + TransakOrderIdTransformer.depositOrderIdToTransakOrderId( + '/providers/transak-native-staging/orders/abc-123', + ), + ).toBe('abc-123'); + }); + + it('handles production deposit order IDs', () => { + expect( + TransakOrderIdTransformer.depositOrderIdToTransakOrderId( + '/providers/transak-native/orders/xyz-789', + ), + ).toBe('xyz-789'); + }); + + it('returns the input if it has no slashes', () => { + expect( + TransakOrderIdTransformer.depositOrderIdToTransakOrderId('simple-id'), + ).toBe('simple-id'); + }); + }); + + describe('transakOrderIdToDepositOrderId', () => { + it('builds a staging deposit order ID', () => { + expect( + TransakOrderIdTransformer.transakOrderIdToDepositOrderId( + 'order-123', + TransakEnvironment.Staging, + ), + ).toBe('/providers/transak-native-staging/orders/order-123'); + }); + + it('builds a production deposit order ID', () => { + expect( + TransakOrderIdTransformer.transakOrderIdToDepositOrderId( + 'order-456', + TransakEnvironment.Production, + ), + ).toBe('/providers/transak-native/orders/order-456'); + }); + }); + + describe('isDepositOrderId', () => { + it('returns true for deposit-format order IDs', () => { + expect( + TransakOrderIdTransformer.isDepositOrderId( + '/providers/transak-native-staging/orders/abc', + ), + ).toBe(true); + }); + + it('returns false for raw Transak order IDs', () => { + expect(TransakOrderIdTransformer.isDepositOrderId('raw-order-id')).toBe( + false, + ); + }); + }); + + describe('extractTransakOrderId', () => { + it('extracts from deposit order IDs', () => { + expect( + TransakOrderIdTransformer.extractTransakOrderId( + '/providers/transak-native/orders/extracted-id', + ), + ).toBe('extracted-id'); + }); + + it('returns raw IDs unchanged', () => { + expect( + TransakOrderIdTransformer.extractTransakOrderId('already-raw'), + ).toBe('already-raw'); + }); + }); +}); diff --git a/packages/ramps-controller/src/TransakService.ts b/packages/ramps-controller/src/TransakService.ts new file mode 100644 index 00000000000..3fc1b3041e3 --- /dev/null +++ b/packages/ramps-controller/src/TransakService.ts @@ -0,0 +1,1153 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; + +import type { TransakServiceMethodActions } from './TransakService-method-action-types'; + +// === TYPES === + +export type TransakAccessToken = { + accessToken: string; + ttl: number; + created: Date; +}; + +export type TransakUserDetails = { + id: string; + firstName: string; + lastName: string; + email: string; + mobileNumber: string; + status: string; + dob: string; + kyc: TransakUserDetailsKycDetails; + address: TransakUserDetailsAddress; + createdAt: string; +}; + +export type TransakUserDetailsAddress = { + addressLine1: string; + addressLine2: string; + state: string; + city: string; + postCode: string; + country: string; + countryCode: string; +}; + +export type TransakUserDetailsKycDetails = { + status: string; + type: string; + attempts: TransakUserDetailsKycAttempt[]; + highestApprovedKYCType: string | null; + kycMarkedBy: string | null; + kycResult: string | null; + rejectionDetails: TransakUserDetailsKycAttemptRejectionDetails | null; + userId: string; + workFlowRunId: string; +}; + +export type TransakUserDetailsKycAttempt = { + artifacts: { key: string; value: string }[]; + metadata: { + transaction: { + kycVendorId: string; + scanReference: string; + workflowId: string; + }; + }; + rejectionDetails: TransakUserDetailsKycAttemptRejectionDetails; + result: string; + sessionId: string; +}; + +export type TransakUserDetailsKycAttemptRejectionDetails = { + archetype: string; + reason: string; + reasonCode: string; +}; + +export type TransakBuyQuote = { + quoteId: string; + conversionPrice: number; + marketConversionPrice: number; + slippage: number; + fiatCurrency: string; + cryptoCurrency: string; + paymentMethod: string; + fiatAmount: number; + cryptoAmount: number; + isBuyOrSell: string; + network: string; + feeDecimal: number; + totalFee: number; + feeBreakdown: { [prop: string]: string | number | boolean | null }[]; + nonce: number; + cryptoLiquidityProvider: string; + notes: { [prop: string]: string | number | boolean | null }[]; +}; + +export type TransakKycRequirement = { + status: + | 'NOT_SUBMITTED' + | 'APPROVED' + | 'ADDITIONAL_FORMS_REQUIRED' + | 'SUBMITTED'; + kycType: string; + isAllowedToPlaceOrder: boolean; +}; + +export type TransakAdditionalRequirement = { + type: string; + metadata?: { + options: string[]; + documentProofOptions: string[]; + expiresAt: string; + kycUrl: string; + workFlowRunId: string; + }; +}; + +export type TransakAdditionalRequirementsResponse = { + formsRequired: TransakAdditionalRequirement[]; +}; + +export type TransakOttResponse = { + ott: string; +}; + +export type TransakOrderPaymentMethod = { + fiatCurrency: string; + paymentMethod: string; + fields: { name: string; id: string; value: string }[]; +}; + +export type TransakDepositNetwork = { + name: string; + chainId: string; +}; + +export type TransakDepositCryptoCurrency = { + assetId: string; + name: string; + chainId: string; + decimals: number; + iconUrl: string; + symbol: string; +}; + +export type TransakDepositPaymentMethod = { + id: string; + name: string; + shortName?: string; + duration: string; + icon: string; + iconColor?: { light: string; dark: string }; + isManualBankTransfer?: boolean; +}; + +export type TransakDepositRegion = { + isoCode: string; + flag: string; + name: string; + phone: { prefix: string; placeholder: string; template: string }; + currency: string; + supported: boolean; + recommended?: boolean; + geolocated?: boolean; +}; + +export type TransakDepositOrder = { + id: string; + provider: string; + cryptoAmount: number | string; + fiatAmount: number; + cryptoCurrency: TransakDepositCryptoCurrency; + fiatCurrency: string; + providerOrderId: string; + providerOrderLink: string; + createdAt: number; + paymentMethod: TransakDepositPaymentMethod; + totalFeesFiat: number; + txHash: string; + walletAddress: string; + status: string; + network: TransakDepositNetwork; + timeDescriptionPending: string; + fiatAmountInUsd: number; + feesInUsd: number; + region: TransakDepositRegion; + orderType: 'DEPOSIT'; + exchangeRate?: number; + statusDescription?: string; + paymentDetails: TransakOrderPaymentMethod[]; + partnerFees?: number; + networkFees?: number; +}; + +export type TransakOrder = { + orderId: string; + partnerUserId: string; + status: string; + isBuyOrSell: string; + fiatCurrency: string; + cryptoCurrency: string; + network: string; + walletAddress: string; + quoteId: string; + fiatAmount: number; + fiatAmountInUsd: number; + amountPaid: number; + cryptoAmount: number; + conversionPrice: number; + totalFeeInFiat: number; + paymentDetails: TransakOrderPaymentMethod[]; + txHash: string; + transationLink: string | null; + createdAt: string; + updatedAt: string; + completedAt: string; +}; + +export type TransakQuoteTranslation = { + region: string; + paymentMethod: string | undefined; + cryptoCurrency: string; + network: string; + fiatCurrency: string; +}; + +export type TransakTranslationRequest = { + regionId?: string; + cryptoCurrencyId?: string; + chainId?: string; + fiatCurrencyId?: string; + paymentMethod?: string; +}; + +export type TransakUserLimits = { + limits: { '1': number; '30': number; '365': number }; + spent: { '1': number; '30': number; '365': number }; + remaining: { '1': number; '30': number; '365': number }; + exceeded: { '1': boolean; '30': boolean; '365': boolean }; + shortage: Record; +}; + +export type PatchUserRequestBody = Partial<{ + personalDetails: Partial<{ + firstName: string; + lastName: string; + mobileNumber: string; + dob: string; + }>; + addressDetails: Partial<{ + addressLine1: string; + addressLine2: string; + state: string; + city: string; + postCode: string; + countryCode: string; + }>; +}>; + +export type TransakIdProofStatus = { + status: 'NOT_SUBMITTED' | 'SUBMITTED'; + kycType: string; + randomLogIdentifier: string; +}; + +// === ENVIRONMENT === + +export enum TransakEnvironment { + Production = 'production', + Staging = 'staging', +} + +enum TransakApiProviders { + TransakNative = 'transak-native', + TransakNativeStaging = 'transak-native-staging', +} + +// === ORDER ID UTILITIES === + +export class TransakOrderIdTransformer { + static depositOrderIdToTransakOrderId(depositOrderId: string): string { + const parts = depositOrderId.split('/'); + return parts[parts.length - 1]; + } + + static transakOrderIdToDepositOrderId( + transakOrderId: string, + environment: TransakEnvironment, + ): string { + const provider = + environment === TransakEnvironment.Staging + ? 'transak-native-staging' + : 'transak-native'; + return `/providers/${provider}/orders/${transakOrderId}`; + } + + static isDepositOrderId(orderId: string): boolean { + return orderId.startsWith('/providers/'); + } + + static extractTransakOrderId(orderId: string): string { + return this.isDepositOrderId(orderId) + ? this.depositOrderIdToTransakOrderId(orderId) + : orderId; + } +} + +// === MESSENGER === + +const serviceName = 'TransakService'; + +const MESSENGER_EXPOSED_METHODS = [ + 'setApiKey', + 'setAccessToken', + 'clearAccessToken', + 'sendUserOtp', + 'verifyUserOtp', + 'logout', + 'getUserDetails', + 'getBuyQuote', + 'getKycRequirement', + 'getAdditionalRequirements', + 'createOrder', + 'getOrder', + 'getUserLimits', + 'requestOtt', + 'generatePaymentWidgetUrl', + 'submitPurposeOfUsageForm', + 'patchUser', + 'submitSsnDetails', + 'confirmPayment', + 'getTranslation', + 'getIdProofStatus', + 'cancelOrder', + 'cancelAllActiveOrders', + 'getActiveOrders', +] as const; + +export type TransakServiceActions = TransakServiceMethodActions; + +type AllowedActions = never; + +export type TransakServiceEvents = never; + +type AllowedEvents = never; + +export type TransakServiceMessenger = Messenger< + typeof serviceName, + TransakServiceActions | AllowedActions, + TransakServiceEvents | AllowedEvents +>; + +// === HELPER FUNCTIONS === + +/** + * Maps ramps API payment method IDs (e.g., "/payments/debit-credit-card") + * to the deposit-format IDs expected by the translation endpoint + * (e.g., "credit_debit_card"). + * + * The translation endpoint only understands the deposit-format IDs. + * If no mapping exists, the input is returned as-is (it may already be + * in the deposit format). + */ +const RAMPS_TO_DEPOSIT_PAYMENT_METHOD: Record = { + '/payments/debit-credit-card': 'credit_debit_card', + '/payments/apple-pay': 'apple_pay', + '/payments/google-pay': 'google_pay', + '/payments/sepa-bank-transfer': 'sepa_bank_transfer', + '/payments/wire-transfer': 'wire_transfer', + '/payments/gbp-bank-transfer': 'gbp_bank_transfer', +}; + +function normalizePaymentMethodForTranslation( + paymentMethod: string | undefined, +): string | undefined { + if (!paymentMethod) { + return undefined; + } + return RAMPS_TO_DEPOSIT_PAYMENT_METHOD[paymentMethod] ?? paymentMethod; +} + +function getTransakApiBaseUrl(environment: TransakEnvironment): string { + switch (environment) { + case TransakEnvironment.Production: + return 'https://api-gateway.transak.com'; + case TransakEnvironment.Staging: + return 'https://api-gateway-stg.transak.com'; + default: + throw new Error(`Invalid Transak environment: ${String(environment)}`); + } +} + +function getRampsBaseUrl(environment: TransakEnvironment): string { + switch (environment) { + case TransakEnvironment.Production: + return 'https://on-ramp.api.cx.metamask.io'; + case TransakEnvironment.Staging: + return 'https://on-ramp.uat-api.cx.metamask.io'; + default: + throw new Error(`Invalid Transak environment: ${String(environment)}`); + } +} + +function getRampsProviderPath(environment: TransakEnvironment): string { + const providerId = + environment === TransakEnvironment.Staging + ? TransakApiProviders.TransakNativeStaging + : TransakApiProviders.TransakNative; + return `/providers/${providerId}`; +} + +function getPaymentWidgetBaseUrl(environment: TransakEnvironment): string { + switch (environment) { + case TransakEnvironment.Production: + return 'https://global.transak.com'; + case TransakEnvironment.Staging: + return 'https://global-stg.transak.com'; + default: + throw new Error(`Invalid Transak environment: ${String(environment)}`); + } +} + +// === TRANSAK API ERROR === + +const TRANSAK_ORDER_EXISTS_CODE = '4005'; + +export class TransakApiError extends HttpError { + readonly errorCode: string | undefined; + + constructor(status: number, message: string, errorCode?: string) { + super(status, message); + this.errorCode = errorCode; + } +} + +// === SERVICE DEFINITION === + +export class TransakService { + readonly name: typeof serviceName; + + readonly #messenger: TransakServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #policy: ServicePolicy; + + readonly #environment: TransakEnvironment; + + readonly #context: string; + + readonly #orderRetryDelayMs: number; + + #apiKey: string | null = null; + + #accessToken: TransakAccessToken | null = null; + + constructor({ + messenger, + environment = TransakEnvironment.Staging, + context, + fetch: fetchFunction, + apiKey, + policyOptions = {}, + orderRetryDelayMs = 2000, + }: { + messenger: TransakServiceMessenger; + environment?: TransakEnvironment; + context: string; + fetch: typeof fetch; + apiKey?: string; + policyOptions?: CreateServicePolicyOptions; + orderRetryDelayMs?: number; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#policy = createServicePolicy(policyOptions); + this.#environment = environment; + this.#context = context; + this.#apiKey = apiKey ?? null; + this.#orderRetryDelayMs = orderRetryDelayMs; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + setApiKey(apiKey: string): void { + this.#apiKey = apiKey; + } + + getApiKey(): string | null { + return this.#apiKey; + } + + #ensureApiKey(): string { + if (!this.#apiKey) { + throw new Error('Transak API key is required but not set.'); + } + return this.#apiKey; + } + + setAccessToken(token: TransakAccessToken): void { + this.#accessToken = token; + } + + getAccessToken(): TransakAccessToken | null { + return this.#accessToken; + } + + clearAccessToken(): void { + this.#accessToken = null; + } + + #ensureAccessToken(): void { + if (!this.#accessToken?.accessToken) { + throw new HttpError( + 401, + 'Authentication required. Please log in to continue.', + ); + } + + const createdTime = new Date(this.#accessToken.created).getTime(); + const tokenAgeMs = Date.now() - createdTime; + if (tokenAgeMs > this.#accessToken.ttl * 1000) { + this.clearAccessToken(); + throw new HttpError( + 401, + 'Authentication token has expired. Please log in again.', + ); + } + } + + #getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + if (this.#accessToken?.accessToken) { + headers.authorization = this.#accessToken.accessToken; + } + return headers; + } + + async #transakGet( + path: string, + params?: Record, + ): Promise { + const baseUrl = getTransakApiBaseUrl(this.#environment); + const url = new URL(path, baseUrl); + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + url.searchParams.set(key, value); + } + } + } + + const apiKey = this.#ensureApiKey(); + url.searchParams.set('apiKey', apiKey); + + const response = await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url.toString(), { + method: 'GET', + headers: this.#getHeaders(), + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise<{ data: ResponseType }>; + }); + + return response.data; + } + + async #transakPost( + path: string, + body?: Record, + ): Promise { + const apiKey = this.#ensureApiKey(); + const baseUrl = getTransakApiBaseUrl(this.#environment); + const url = new URL(path, baseUrl); + + const requestBody = { + ...(body ?? {}), + apiKey, + }; + + const response = await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url.toString(), { + method: 'POST', + headers: this.#getHeaders(), + body: JSON.stringify(requestBody), + }); + if (!fetchResponse.ok) { + let errorBody = ''; + let errorCode: string | undefined; + try { + errorBody = await fetchResponse.text(); + const parsed = JSON.parse(errorBody) as { + error?: { code?: string }; + }; + errorCode = parsed?.error?.code; + } catch { + // ignore body read/parse failures + } + throw new TransakApiError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'${errorBody ? `: ${errorBody}` : ''}`, + errorCode, + ); + } + return fetchResponse.json() as Promise<{ data: ResponseType }>; + }); + + return response.data; + } + + async #transakPatch( + path: string, + body: Record, + ): Promise { + const apiKey = this.#ensureApiKey(); + const baseUrl = getTransakApiBaseUrl(this.#environment); + const url = new URL(path, baseUrl); + url.searchParams.set('apiKey', apiKey); + + const response = await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url.toString(), { + method: 'PATCH', + headers: this.#getHeaders(), + body: JSON.stringify(body), + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise<{ data: ResponseType }>; + }); + + return response.data; + } + + async #transakDelete( + path: string, + params?: Record, + ): Promise { + const apiKey = this.#ensureApiKey(); + const baseUrl = getTransakApiBaseUrl(this.#environment); + const url = new URL(path, baseUrl); + url.searchParams.set('apiKey', apiKey); + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + url.searchParams.set(key, value); + } + } + } + + await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url.toString(), { + method: 'DELETE', + headers: this.#getHeaders(), + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + }); + } + + async #ordersApiGet( + path: string, + params?: Record, + ): Promise { + const baseUrl = getRampsBaseUrl(this.#environment); + const providerPath = getRampsProviderPath(this.#environment); + const url = new URL(`${providerPath}${path}`, baseUrl); + + url.searchParams.set('action', 'deposit'); + url.searchParams.set('context', this.#context); + + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + url.searchParams.set(key, value); + } + } + } + + const response = await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; + }); + + return response; + } + + // === PUBLIC API METHODS === + + async sendUserOtp(email: string): Promise<{ + isTncAccepted: boolean; + stateToken: string; + email: string; + expiresIn: number; + }> { + const result = await this.#transakPost<{ + isTncAccepted: boolean; + stateToken: string; + email: string; + expiresIn: number; + }>('/api/v2/auth/login', { email }); + return result; + } + + async verifyUserOtp( + email: string, + verificationCode: string, + stateToken: string, + ): Promise { + const responseData = await this.#transakPost<{ + accessToken: string; + ttl: number; + created: string; + }>('/api/v2/auth/verify', { + email, + otp: verificationCode, + stateToken, + }); + + const accessToken: TransakAccessToken = { + accessToken: responseData.accessToken, + ttl: responseData.ttl, + created: new Date(responseData.created), + }; + + this.setAccessToken(accessToken); + return accessToken; + } + + async logout(): Promise { + this.#ensureAccessToken(); + try { + const result = await this.#transakPost('/api/v1/auth/logout'); + this.clearAccessToken(); + return result; + } catch (error) { + if (error instanceof HttpError && error.httpStatus === 401) { + this.clearAccessToken(); + return 'user was already logged out'; + } + throw error; + } + } + + async getUserDetails(): Promise { + this.#ensureAccessToken(); + return this.#transakGet('/api/v2/user/'); + } + + async getBuyQuote( + genericFiatCurrency: string, + genericCryptoCurrency: string, + genericNetwork: string, + genericPaymentMethod: string, + fiatAmount: string, + ): Promise { + const normalizedPaymentMethod = normalizePaymentMethodForTranslation( + genericPaymentMethod || undefined, + ); + const translationRequest = { + cryptoCurrencyId: genericCryptoCurrency, + chainId: genericNetwork, + fiatCurrencyId: genericFiatCurrency, + paymentMethod: normalizedPaymentMethod, + }; + + const translation = await this.getTranslation(translationRequest); + + const params: Record = { + fiatCurrency: translation.fiatCurrency, + cryptoCurrency: translation.cryptoCurrency, + isBuyOrSell: 'BUY', + network: translation.network, + fiatAmount, + isFeeExcludedFromFiat: 'true', + }; + + if (translation.paymentMethod) { + params.paymentMethod = translation.paymentMethod; + } + + return this.#transakGet('/api/v2/lookup/quotes', params); + } + + async getKycRequirement(quoteId: string): Promise { + this.#ensureAccessToken(); + const result = await this.#transakGet( + '/api/v2/kyc/requirement', + { + 'metadata[quoteId]': quoteId, + }, + ); + return result; + } + + async getAdditionalRequirements( + quoteId: string, + ): Promise { + this.#ensureAccessToken(); + return this.#transakGet( + '/api/v2/kyc/additional-requirements', + { 'metadata[quoteId]': quoteId }, + ); + } + + async createOrder( + quoteId: string, + walletAddress: string, + paymentMethodId: string, + ): Promise { + this.#ensureAccessToken(); + + const normalizedPaymentMethod = + normalizePaymentMethodForTranslation(paymentMethodId); + const translation = await this.getTranslation({ + paymentMethod: normalizedPaymentMethod, + }); + + const paymentInstrumentId = + translation.paymentMethod ?? normalizedPaymentMethod; + + try { + const transakOrder = await this.#transakPost( + '/api/v2/orders', + { + quoteId, + walletAddress, + paymentInstrumentId, + }, + ); + + const depositOrderId = + TransakOrderIdTransformer.transakOrderIdToDepositOrderId( + transakOrder.orderId, + this.#environment, + ); + + return this.getOrder( + depositOrderId, + transakOrder.walletAddress, + transakOrder.paymentDetails, + ); + } catch (error) { + if ( + error instanceof TransakApiError && + error.httpStatus === 409 && + error.errorCode === TRANSAK_ORDER_EXISTS_CODE + ) { + await this.cancelAllActiveOrders(); + await new Promise((resolve) => + setTimeout(resolve, this.#orderRetryDelayMs), + ); + + const retryOrder = await this.#transakPost( + '/api/v2/orders', + { + quoteId, + walletAddress, + paymentInstrumentId, + }, + ); + + const retryDepositOrderId = + TransakOrderIdTransformer.transakOrderIdToDepositOrderId( + retryOrder.orderId, + this.#environment, + ); + + return this.getOrder( + retryDepositOrderId, + retryOrder.walletAddress, + retryOrder.paymentDetails, + ); + } + throw error; + } + } + + async getOrder( + orderId: string, + wallet: string, + paymentDetails?: TransakOrderPaymentMethod[], + ): Promise { + let depositOrderId: string; + if (TransakOrderIdTransformer.isDepositOrderId(orderId)) { + depositOrderId = orderId; + } else { + depositOrderId = TransakOrderIdTransformer.transakOrderIdToDepositOrderId( + orderId, + this.#environment, + ); + } + + const transakOrderId = + TransakOrderIdTransformer.extractTransakOrderId(depositOrderId); + + const order = await this.#ordersApiGet( + `/orders/${transakOrderId}`, + { wallet }, + ); + + const orderWithId = { + ...order, + id: depositOrderId, + orderType: 'DEPOSIT' as const, + }; + + if (paymentDetails && paymentDetails.length > 0) { + return { ...orderWithId, paymentDetails }; + } + + if (this.#accessToken?.accessToken) { + try { + const transakOrder = await this.#transakGet( + `/api/v2/orders/${transakOrderId}`, + ); + return { ...orderWithId, paymentDetails: transakOrder.paymentDetails }; + } catch { + return orderWithId; + } + } + + return orderWithId; + } + + async getUserLimits( + fiatCurrency: string, + paymentMethod: string, + kycType: string, + ): Promise { + this.#ensureAccessToken(); + + const translation = await this.getTranslation({ + paymentMethod: normalizePaymentMethodForTranslation(paymentMethod), + }); + + const params: Record = { + isBuyOrSell: 'BUY', + kycType, + fiatCurrency, + }; + + if (translation.paymentMethod) { + params.paymentCategory = translation.paymentMethod; + } + + return this.#transakGet( + '/api/v2/orders/user-limit', + params, + ); + } + + async requestOtt(): Promise { + this.#ensureAccessToken(); + const result = await this.#transakPost( + '/api/v2/auth/request-ott', + ); + return result; + } + + generatePaymentWidgetUrl( + ottToken: string, + quote: TransakBuyQuote, + walletAddress: string, + extraParams?: Record, + ): string { + const apiKey = this.#ensureApiKey(); + const widgetBaseUrl = getPaymentWidgetBaseUrl(this.#environment); + + const defaultParams: Record = { + apiKey, + ott: ottToken, + fiatCurrency: quote.fiatCurrency, + cryptoCurrencyCode: quote.cryptoCurrency, + productsAvailed: 'BUY', + fiatAmount: quote.fiatAmount.toString(), + network: quote.network, + hideExchangeScreen: 'true', + walletAddress, + disableWalletAddressForm: 'true', + paymentMethod: quote.paymentMethod, + redirectURL: + 'https://on-ramp-content.api.cx.metamask.io/regions/fake-callback', + hideMenu: 'true', + }; + + const params = new URLSearchParams({ + ...defaultParams, + ...extraParams, + }); + + const widgetUrl = new URL(widgetBaseUrl); + widgetUrl.search = params.toString(); + return widgetUrl.toString(); + } + + async submitPurposeOfUsageForm(purpose: string[]): Promise { + this.#ensureAccessToken(); + await this.#transakPost('/api/v2/kyc/purpose-of-usage', { + purposeList: purpose, + }); + } + + async patchUser(data: PatchUserRequestBody): Promise { + this.#ensureAccessToken(); + return this.#transakPatch( + '/api/v2/kyc/user', + data as Record, + ); + } + + async submitSsnDetails(ssn: string, quoteId: string): Promise { + this.#ensureAccessToken(); + return this.#transakPost('/api/v2/kyc/ssn', { ssn, quoteId }); + } + + async confirmPayment( + orderId: string, + paymentMethodId: string, + ): Promise<{ success: boolean }> { + this.#ensureAccessToken(); + + const normalizedPaymentMethod = + normalizePaymentMethodForTranslation(paymentMethodId); + const translation = await this.getTranslation({ + paymentMethod: normalizedPaymentMethod, + }); + + const transakOrderId = + TransakOrderIdTransformer.extractTransakOrderId(orderId); + + return this.#transakPost<{ success: boolean }>( + '/api/v2/orders/payment-confirmation', + { + orderId: transakOrderId, + paymentMethod: translation.paymentMethod ?? normalizedPaymentMethod, + }, + ); + } + + async getTranslation( + translationRequest: TransakTranslationRequest, + ): Promise { + const baseUrl = getRampsBaseUrl(this.#environment); + const providerPath = getRampsProviderPath(this.#environment); + const url = new URL(`${providerPath}/native/translate`, baseUrl); + + url.searchParams.set('action', 'deposit'); + url.searchParams.set('context', this.#context); + + const normalizedRequest = { + ...translationRequest, + paymentMethod: normalizePaymentMethodForTranslation( + translationRequest.paymentMethod, + ), + }; + + for (const [key, value] of Object.entries(normalizedRequest)) { + if (value !== undefined) { + url.searchParams.set(key, value); + } + } + + const response = await this.#policy.execute(async () => { + const fetchResponse = await this.#fetch(url.toString(), { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; + }); + + return response; + } + + async getIdProofStatus(workFlowRunId: string): Promise { + this.#ensureAccessToken(); + return this.#transakGet( + '/api/v2/kyc/id-proof-status', + { workFlowRunId }, + ); + } + + async cancelOrder(depositOrderId: string): Promise { + this.#ensureAccessToken(); + const transakOrderId = + TransakOrderIdTransformer.extractTransakOrderId(depositOrderId); + await this.#transakDelete(`/api/v2/orders/${transakOrderId}`, { + cancelReason: 'Creating new order', + }); + } + + async cancelAllActiveOrders(): Promise { + this.#ensureAccessToken(); + const activeOrders = await this.getActiveOrders(); + const errors: Error[] = []; + + await Promise.all( + activeOrders.map(async (order) => { + try { + const depositOrderId = + TransakOrderIdTransformer.transakOrderIdToDepositOrderId( + order.orderId, + this.#environment, + ); + await this.cancelOrder(depositOrderId); + } catch (error) { + errors.push( + error instanceof Error ? error : new Error(String(error)), + ); + } + }), + ); + + return errors; + } + + async getActiveOrders(): Promise { + this.#ensureAccessToken(); + return this.#transakGet('/api/v2/active-orders'); + } +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 1d357a4aaea..05ff54ead10 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -8,6 +8,8 @@ export type { RampsControllerOptions, UserRegion, ResourceState, + TransakState, + NativeProvidersState, } from './RampsController'; export { RampsController, @@ -25,6 +27,7 @@ export type { Provider, ProviderLink, ProviderLogos, + ProviderBrowserType, RampAction, PaymentMethod, PaymentMethodsResponse, @@ -38,6 +41,7 @@ export type { GetQuotesParams, RampsToken, TokensResponse, + BuyWidget, } from './RampsService'; export { RampsService, @@ -50,6 +54,7 @@ export type { RampsServiceGetCountriesAction, RampsServiceGetPaymentMethodsAction, RampsServiceGetQuotesAction, + RampsServiceGetBuyWidgetUrlAction, } from './RampsService-method-action-types'; export type { RequestCache, @@ -70,3 +75,47 @@ export { } from './RequestCache'; export type { RequestSelectorResult } from './selectors'; export { createRequestSelector } from './selectors'; +export type { + TransakServiceActions, + TransakServiceEvents, + TransakServiceMessenger, + TransakAccessToken, + TransakUserDetails, + TransakUserDetailsAddress, + TransakUserDetailsKycDetails, + TransakBuyQuote, + TransakKycRequirement, + TransakAdditionalRequirement, + TransakAdditionalRequirementsResponse, + TransakOttResponse, + TransakOrderPaymentMethod, + TransakDepositOrder, + TransakDepositNetwork, + TransakDepositCryptoCurrency, + TransakDepositPaymentMethod, + TransakDepositRegion, + TransakOrder, + TransakQuoteTranslation, + TransakTranslationRequest, + TransakUserLimits, + TransakIdProofStatus, + PatchUserRequestBody as TransakPatchUserRequestBody, +} from './TransakService'; +export { + TransakApiError, + TransakService, + TransakEnvironment, + TransakOrderIdTransformer, +} from './TransakService'; +export type { + TransakServiceMethodActions, + TransakServiceSendUserOtpAction, + TransakServiceVerifyUserOtpAction, + TransakServiceGetUserDetailsAction, + TransakServiceGetBuyQuoteAction, + TransakServiceGetKycRequirementAction, + TransakServiceCreateOrderAction, + TransakServiceGetOrderAction, + TransakServiceRequestOttAction, + TransakServiceGeneratePaymentWidgetUrlAction, +} from './TransakService-method-action-types'; diff --git a/packages/ramps-controller/src/selectors.test.ts b/packages/ramps-controller/src/selectors.test.ts index 2030574fc16..459ad64dedf 100644 --- a/packages/ramps-controller/src/selectors.test.ts +++ b/packages/ramps-controller/src/selectors.test.ts @@ -65,7 +65,7 @@ describe('createRequestSelector', () => { const result = selector(state); expect(result).toMatchInlineSnapshot(` - Object { + { "data": null, "error": null, "isFetching": true, @@ -92,8 +92,8 @@ describe('createRequestSelector', () => { const result = selector(state); expect(result).toMatchInlineSnapshot(` - Object { - "data": Array [ + { + "data": [ "ETH", "BTC", ], @@ -122,7 +122,7 @@ describe('createRequestSelector', () => { const result = selector(state); expect(result).toMatchInlineSnapshot(` - Object { + { "data": null, "error": "Network error", "isFetching": false, @@ -144,7 +144,7 @@ describe('createRequestSelector', () => { const result = selector(state); expect(result).toMatchInlineSnapshot(` - Object { + { "data": null, "error": null, "isFetching": false, @@ -166,7 +166,7 @@ describe('createRequestSelector', () => { const result = selector(state); expect(result).toMatchInlineSnapshot(` - Object { + { "data": null, "error": null, "isFetching": false, diff --git a/packages/rate-limit-controller/package.json b/packages/rate-limit-controller/package.json index a603e1fdbb0..a1ffc282e08 100644 --- a/packages/rate-limit-controller/package.json +++ b/packages/rate-limit-controller/package.json @@ -56,11 +56,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/rate-limit-controller/src/RateLimitController.test.ts b/packages/rate-limit-controller/src/RateLimitController.test.ts index bf6883e305a..128259690e0 100644 --- a/packages/rate-limit-controller/src/RateLimitController.test.ts +++ b/packages/rate-limit-controller/src/RateLimitController.test.ts @@ -257,7 +257,7 @@ describe('RateLimitController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -272,7 +272,7 @@ describe('RateLimitController', () => { controller.metadata, 'includeInStateLogs', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('persists expected state', () => { @@ -287,7 +287,7 @@ describe('RateLimitController', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -302,7 +302,7 @@ describe('RateLimitController', () => { controller.metadata, 'usedInUi', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); }); diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 2bebdf0741c..56ddba48112 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.17.0` to `^11.18.0` ([#7583](https://github.com/MetaMask/core/pull/7583)) +### Fixed + +- Add optional `prevClientVersion` constructor argument to invalidate cached flags when the client version changes ([#7827](https://github.com/MetaMask/core/pull/7827)) + ## [4.0.0] ### Changed diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json index b95a1bd948f..7cb80e80045 100644 --- a/packages/remote-feature-flag-controller/package.json +++ b/packages/remote-feature-flag-controller/package.json @@ -58,12 +58,12 @@ "@lavamoat/allow-scripts": "^3.0.4", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, 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 3de6ce24ed7..2ea779a4ae9 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 @@ -18,6 +18,7 @@ import type { RemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; +import { flushPromises } from '../../../tests/helpers'; const MOCK_FLAGS: FeatureFlags = { feature1: true, @@ -65,6 +66,7 @@ function createController( disabled: boolean; getMetaMetricsId: () => string; clientVersion: string; + prevClientVersion: string; }> = {}, ): RemoteFeatureFlagController { return new RemoteFeatureFlagController({ @@ -77,6 +79,7 @@ function createController( options.getMetaMetricsId ?? ((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID), clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, + prevClientVersion: options.prevClientVersion, }); } @@ -186,6 +189,94 @@ describe('RemoteFeatureFlagController', () => { expect(controller.state.remoteFeatureFlags).toStrictEqual(MOCK_FLAGS); }); + it('resets cache and fetch when clientVersion changes', async () => { + const versionedFlags = { + exploreFeature: { + versions: { + '7.62.0': { enabled: false }, + '7.64.0': { enabled: true }, + }, + }, + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: versionedFlags, + }); + + /** + * Test Util - Arrange, Act, Assert for client version change + * + * @param opts - The options for the arrangeActAssertClientVersionChange test + * @param opts.clientVersion - The client version to use + * @param opts.prevClientVersion - The previous client version to use + * @param opts.controllerState - The controller state to use + * @param opts.expectedFeatureFlagState - The expected feature flag state + * @returns The controller state after the arrangeActAssertClientVersionChange test + */ + const arrangeActAssertClientVersionChange = async (opts: { + clientVersion: string; + prevClientVersion?: string; + controllerState?: RemoteFeatureFlagControllerState; + expectedFeatureFlagState: boolean; + }): Promise => { + const { + clientVersion, + prevClientVersion, + controllerState, + expectedFeatureFlagState, + } = opts; + + // Arrange - setup controller + jest.clearAllMocks(); + const controller = createController({ + clientConfigApiService, + clientVersion, + prevClientVersion, + state: controllerState, + }); + + // Assert - controller cache is set to 0 (either by initial state or reset by client version change) + expect(controller.state.cacheTimestamp).toBe(0); + + // Act / Assert - We should make a network request and update the cache (cache is reset to 0) + await controller.updateRemoteFeatureFlags(); + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).toHaveBeenCalledTimes(1); + + // Act / Assert - subsequent fetches should not call network again (cache is populated) + await controller.updateRemoteFeatureFlags(); + expect( + clientConfigApiService.fetchRemoteFeatureFlags, + ).toHaveBeenCalledTimes(1); + + // Assert - flag state is as expected + expect( + controller.state.remoteFeatureFlags.exploreFeature, + ).toStrictEqual({ + enabled: expectedFeatureFlagState, + }); + + // Assert - cache timestamp has been updated + expect(controller.state.cacheTimestamp).toBeGreaterThan(0); + + return controller.state; + }; + + // Test: New controller initialized (no previous state) + const controllerState = await arrangeActAssertClientVersionChange({ + clientVersion: '7.62.0', + expectedFeatureFlagState: false, + }); + + // Test: Updated controller with a previous client version + await arrangeActAssertClientVersionChange({ + clientVersion: '7.64.0', + prevClientVersion: '7.62.0', + controllerState, + expectedFeatureFlagState: true, + }); + }); + it('makes a network request to fetch when cache is expired, and then updates the cache', async () => { const clientConfigApiService = buildClientConfigApiService({ cacheTimestamp: Date.now() - 10000, @@ -932,6 +1023,7 @@ describe('RemoteFeatureFlagController', () => { describe('threshold cache cleanup', () => { it('removes stale threshold cache entries when flags are removed from server', async () => { + jest.useRealTimers(); // Arrange const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: { @@ -983,6 +1075,7 @@ describe('RemoteFeatureFlagController', () => { // Second update: flagA removed from server await controller.updateRemoteFeatureFlags(); + await flushPromises(); // Assert - flagA cache entry removed const cacheAfterSecond = controller.state.thresholdCache ?? {}; @@ -1283,11 +1376,11 @@ describe('RemoteFeatureFlagController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "cacheTimestamp": 0, - "localOverrides": Object {}, - "rawRemoteFeatureFlags": Object {}, - "remoteFeatureFlags": Object {}, + "localOverrides": {}, + "rawRemoteFeatureFlags": {}, + "remoteFeatureFlags": {}, } `); }); @@ -1302,11 +1395,11 @@ describe('RemoteFeatureFlagController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "cacheTimestamp": 0, - "localOverrides": Object {}, - "rawRemoteFeatureFlags": Object {}, - "remoteFeatureFlags": Object {}, + "localOverrides": {}, + "rawRemoteFeatureFlags": {}, + "remoteFeatureFlags": {}, } `); }); @@ -1321,11 +1414,11 @@ describe('RemoteFeatureFlagController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "cacheTimestamp": 0, - "localOverrides": Object {}, - "rawRemoteFeatureFlags": Object {}, - "remoteFeatureFlags": Object {}, + "localOverrides": {}, + "rawRemoteFeatureFlags": {}, + "remoteFeatureFlags": {}, } `); }); @@ -1340,9 +1433,9 @@ describe('RemoteFeatureFlagController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "localOverrides": Object {}, - "remoteFeatureFlags": Object {}, + { + "localOverrides": {}, + "remoteFeatureFlags": {}, } `); }); 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 c26fddf174c..f05d3147ab1 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 @@ -168,6 +168,7 @@ export class RemoteFeatureFlagController extends BaseController< * @param options.disabled - Determines if the controller should be disabled initially. Defaults to false. * @param options.getMetaMetricsId - Returns metaMetricsId. * @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string. + * @param options.prevClientVersion - The previous client version for feature flag cache invalidation. */ constructor({ messenger, @@ -177,6 +178,7 @@ export class RemoteFeatureFlagController extends BaseController< disabled = false, getMetaMetricsId, clientVersion, + prevClientVersion, }: { messenger: RemoteFeatureFlagControllerMessenger; state?: Partial; @@ -185,6 +187,7 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval?: number; disabled?: boolean; clientVersion: string; + prevClientVersion?: string; }) { if (!isValidSemVerVersion(clientVersion)) { throw new Error( @@ -192,13 +195,24 @@ export class RemoteFeatureFlagController extends BaseController< ); } + const initialState: RemoteFeatureFlagControllerState = { + ...getDefaultRemoteFeatureFlagControllerState(), + ...state, + }; + + const hasClientVersionChanged = + isValidSemVerVersion(prevClientVersion) && + prevClientVersion !== clientVersion; + super({ name: controllerName, metadata: remoteFeatureFlagControllerMetadata, messenger, state: { - ...getDefaultRemoteFeatureFlagControllerState(), - ...state, + ...initialState, + cacheTimestamp: hasClientVersionChanged + ? 0 + : initialState.cacheTimestamp, }, }); diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index f7548e94dce..eca34b378f6 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -57,13 +57,12 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/controller-utils": "^11.18.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/sample-controllers/src/sample-gas-prices-controller.test.ts b/packages/sample-controllers/src/sample-gas-prices-controller.test.ts index 4b62cdfaef7..0d4eeff1a37 100644 --- a/packages/sample-controllers/src/sample-gas-prices-controller.test.ts +++ b/packages/sample-controllers/src/sample-gas-prices-controller.test.ts @@ -36,8 +36,8 @@ describe('SampleGasPricesController', () => { it('fills in missing initial state with defaults', async () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "gasPricesByChainId": Object {}, + { + "gasPricesByChainId": {}, } `); }); @@ -346,7 +346,7 @@ describe('SampleGasPricesController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -359,8 +359,8 @@ describe('SampleGasPricesController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "gasPricesByChainId": Object {}, + { + "gasPricesByChainId": {}, } `); }); @@ -375,8 +375,8 @@ describe('SampleGasPricesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "gasPricesByChainId": Object {}, + { + "gasPricesByChainId": {}, } `); }); @@ -391,8 +391,8 @@ describe('SampleGasPricesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "gasPricesByChainId": Object {}, + { + "gasPricesByChainId": {}, } `); }); diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts index d77fc158402..beb84755722 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts @@ -6,21 +6,17 @@ import type { MessengerEvents, } from '@metamask/messenger'; import nock from 'nock'; -import { useFakeTimers } from 'sinon'; -import type { SinonFakeTimers } from 'sinon'; import type { SampleGasPricesServiceMessenger } from './sample-gas-prices-service'; import { SampleGasPricesService } from './sample-gas-prices-service'; describe('SampleGasPricesService', () => { - let clock: SinonFakeTimers; - beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('SampleGasPricesService:fetchGasPrices', () => { @@ -79,7 +75,7 @@ describe('SampleGasPricesService', () => { .get('/gas-prices') .query({ chainId: 'eip155:1' }) .reply(200, () => { - clock.tick(6000); + jest.advanceTimersByTime(6000); return { data: { low: 5, @@ -102,7 +98,7 @@ describe('SampleGasPricesService', () => { .get('/gas-prices') .query({ chainId: 'eip155:1' }) .reply(200, () => { - clock.tick(1000); + jest.advanceTimersByTime(1000); return { data: { low: 5, @@ -132,7 +128,7 @@ describe('SampleGasPricesService', () => { .reply(500); const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); }); await expect( @@ -150,7 +146,7 @@ describe('SampleGasPricesService', () => { .reply(500); const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); }); const onDegradedListener = jest.fn(); service.onDegraded(onDegradedListener); @@ -171,7 +167,7 @@ describe('SampleGasPricesService', () => { .reply(500); const { service, rootMessenger } = getService(); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); }); const onBreakListener = jest.fn(); service.onBreak(onBreakListener); @@ -231,7 +227,7 @@ describe('SampleGasPricesService', () => { }, }); service.onRetry(() => { - clock.nextAsync().catch(console.error); + jest.advanceTimersToNextTimerAsync().catch(console.error); }); await expect( @@ -254,7 +250,7 @@ describe('SampleGasPricesService', () => { ).rejects.toThrow( 'Execution prevented because the circuit breaker is open', ); - await clock.tickAsync(circuitBreakDuration); + await jest.advanceTimersByTimeAsync(circuitBreakDuration); const gasPricesResponse = await service.fetchGasPrices('0x1'); expect(gasPricesResponse).toStrictEqual({ low: 5, diff --git a/packages/sample-controllers/src/sample-petnames-controller.test.ts b/packages/sample-controllers/src/sample-petnames-controller.test.ts index 96e45b803da..c8e7e0251c6 100644 --- a/packages/sample-controllers/src/sample-petnames-controller.test.ts +++ b/packages/sample-controllers/src/sample-petnames-controller.test.ts @@ -33,8 +33,8 @@ describe('SamplePetnamesController', () => { it('fills in missing initial state with defaults', async () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { - "namesByChainIdAndAddress": Object {}, + { + "namesByChainIdAndAddress": {}, } `); }); @@ -201,7 +201,7 @@ describe('SamplePetnamesController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); }); @@ -214,8 +214,8 @@ describe('SamplePetnamesController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "namesByChainIdAndAddress": Object {}, + { + "namesByChainIdAndAddress": {}, } `); }); @@ -230,8 +230,8 @@ describe('SamplePetnamesController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "namesByChainIdAndAddress": Object {}, + { + "namesByChainIdAndAddress": {}, } `); }); @@ -246,8 +246,8 @@ describe('SamplePetnamesController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "namesByChainIdAndAddress": Object {}, + { + "namesByChainIdAndAddress": {}, } `); }); diff --git a/packages/seedless-onboarding-controller/CHANGELOG.md b/packages/seedless-onboarding-controller/CHANGELOG.md index 5293235b901..dff0bf8d459 100644 --- a/packages/seedless-onboarding-controller/CHANGELOG.md +++ b/packages/seedless-onboarding-controller/CHANGELOG.md @@ -7,12 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [8.0.0] + +### Added + +- Add new `SeedlessOnboardingError` class for generic controller errors with support for `cause` and `details` properties ([#7660](https://github.com/MetaMask/core/pull/7660)) + - Enables proper error chaining by wrapping underlying errors with additional context + - Includes `toJSON()` method for serialization in logging/transmission +- **BREAKING** The `encryptor` constructor param requires `encryptWithKey` method. ([#7800](https://github.com/MetaMask/core/pull/7800)) + - The method is to encrypt the vault with cached encryption key while the wallet is unlocked. +- Added new public method, `getAccessToken`. ([#7800](https://github.com/MetaMask/core/pull/7800)) + - Clients can use this method to get `accessToken` from the controller, instead of directly accessing from the state. + - This method also adds refresh token mechanism when `accessToken` is expired, hence preventing expired token usage in the clients. + ### Changed - Update StateMetadata's `includeInStateLogs` property. ([#7750](https://github.com/MetaMask/core/pull/7750)) - Exclude All the tokens values from the state log explicitly. - Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713)) - Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Refactor controller methods to throw `SeedlessOnboardingError` with original error as `cause` for better error tracing ([#7660](https://github.com/MetaMask/core/pull/7660)) + - Affected methods: `authenticate`, `changePassword`, `#persistLocalEncryptionKey`, `#fetchAndParseSecretMetadata`, `refreshAuthTokens` + +### Fixed + +- Fixed new `accessToken` not being persisted in the vault after the token refresh. ([#7800](https://github.com/MetaMask/core/pull/7800)) ## [7.1.0] @@ -261,7 +280,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `checkIsPasswordOutdated`: Check if the password is current device is outdated, i.e. user changed password in another device. - `clearState`: Reset the state of the controller to the defaults. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@7.1.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@8.0.0...HEAD +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@7.1.0...@metamask/seedless-onboarding-controller@8.0.0 [7.1.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@7.0.0...@metamask/seedless-onboarding-controller@7.1.0 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@6.1.0...@metamask/seedless-onboarding-controller@7.0.0 [6.1.0]: https://github.com/MetaMask/core/compare/@metamask/seedless-onboarding-controller@6.0.0...@metamask/seedless-onboarding-controller@6.1.0 diff --git a/packages/seedless-onboarding-controller/jest.environment.js b/packages/seedless-onboarding-controller/jest.environment.js index 97bb00ab28f..96293a73a3f 100644 --- a/packages/seedless-onboarding-controller/jest.environment.js +++ b/packages/seedless-onboarding-controller/jest.environment.js @@ -1,10 +1,10 @@ -const NodeEnvironment = require('jest-environment-node'); +const { TestEnvironment } = require('jest-environment-node'); /** * SeedlessOnboardingController depends on @noble/hashes, which as of 1.7.1 relies on the * Web Crypto API in Node and browsers. */ -class CustomTestEnvironment extends NodeEnvironment { +class CustomTestEnvironment extends TestEnvironment { async setup() { await super.setup(); if (typeof this.global.crypto === 'undefined') { diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index fe11eda5e2c..1b76ac45fe0 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/seedless-onboarding-controller", - "version": "7.1.0", + "version": "8.0.0", "description": "Backup and rehydrate SRP(s) using social login and password", "keywords": [ "MetaMask", @@ -66,14 +66,14 @@ "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", "@types/elliptic": "^6", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/json-stable-stringify-without-jsonify": "^1.0.2", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "jest-environment-node": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", "nock": "^13.3.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts index f6f1b1f9fdc..0bf5baa75f7 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.test.ts @@ -14,6 +14,7 @@ import { exportKey as exportKeyBrowserPassworder, generateSalt as generateSaltBrowserPassworder, keyFromPassword as keyFromPasswordBrowserPassworder, + encryptWithKey as encryptWithKeyBrowserPassworder, } from '@metamask/browser-passworder'; import { TOPRFError, TOPRFErrorCode } from '@metamask/toprf-secure-backup'; import type { @@ -48,10 +49,10 @@ import { SecretMetadata } from './SecretMetadata'; import { getInitialSeedlessOnboardingControllerStateWithDefaults, SeedlessOnboardingController, -} from './SeedlessOnboardingController'; -import type { SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerOptions, +} from './SeedlessOnboardingController'; +import type { SeedlessOnboardingControllerState, VaultEncryptor, } from './types'; @@ -71,6 +72,7 @@ import { MULTIPLE_MOCK_SECRET_METADATA, } from '../tests/mocks/toprf'; import { MockToprfEncryptorDecryptor } from '../tests/mocks/toprfEncryptor'; +import { createMockJWTToken } from '../tests/mocks/utils'; import MockVaultEncryptor from '../tests/mocks/vaultEncryptor'; const authConnection = AuthConnection.Google; @@ -171,6 +173,7 @@ function getDefaultSeedlessOnboardingVaultEncryptor(): VaultEncryptor< exportKey: exportKeyBrowserPassworder, generateSalt: generateSaltBrowserPassworder, keyFromPassword: keyFromPasswordBrowserPassworder, + encryptWithKey: encryptWithKeyBrowserPassworder, }; } @@ -3895,6 +3898,7 @@ describe('SeedlessOnboardingController', () => { describe('syncLatestGlobalPassword', () => { const OLD_PASSWORD = 'old-mock-password'; const GLOBAL_PASSWORD = 'new-global-password'; + const mockToprfEncryptor = createMockToprfEncryptor(); let MOCK_VAULT: string; let MOCK_VAULT_ENCRYPTION_KEY: string; let MOCK_VAULT_ENCRYPTION_SALT: string; @@ -3903,10 +3907,12 @@ describe('SeedlessOnboardingController', () => { let initialEncKey: Uint8Array; // Store initial encKey for vault creation let initialPwEncKey: Uint8Array; // Store initial pwEncKey for vault creation let initialEncryptedSeedlessEncryptionKey: Uint8Array; // Store initial encryptedSeedlessEncryptionKey for vault creation + let newEncKey: Uint8Array; + let newPwEncKey: Uint8Array; + let newAuthKeyPair: KeyPair; // Generate initial keys and vault state before tests run beforeAll(async () => { - const mockToprfEncryptor = createMockToprfEncryptor(); initialEncKey = mockToprfEncryptor.deriveEncKey(OLD_PASSWORD); initialPwEncKey = mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); initialAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(OLD_PASSWORD); @@ -3931,6 +3937,12 @@ describe('SeedlessOnboardingController', () => { }); // Remove beforeEach as setup is done in beforeAll now + beforeEach(() => { + // Mock recoverEncKey for the new global password + newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + newPwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + newAuthKeyPair = mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + }); it('should successfully sync the latest global password', async () => { const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); @@ -3957,14 +3969,6 @@ describe('SeedlessOnboardingController', () => { const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); - // Mock recoverEncKey for the new global password - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - recoverEncKeySpy.mockResolvedValueOnce({ encKey: newEncKey, pwEncKey: newPwEncKey, @@ -4037,6 +4041,110 @@ describe('SeedlessOnboardingController', () => { ); }); + it('should persist the latest accessToken when state token is newer than vault token', async () => { + const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const newerAccessToken = createMockJWTToken({ exp: futureExp }); // refreshed accessToken + const b64EncKey = bytesToBase64(initialEncryptedSeedlessEncryptionKey); + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + authPubKey: INITIAL_AUTH_PUB_KEY, // Use the base64 encoded key + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + withMockAuthPubKey: true, + encryptedSeedlessEncryptionKey: b64EncKey, + }), + }, + async ({ controller, toprfClient, encryptor, mockRefreshJWTToken }) => { + // Unlock controller first - requires vaultEncryptionKey/Salt or password + // Since we provide key/salt in state, submitPassword isn't strictly needed here + // but we keep it to match the method's requirement of being unlocked + // We'll use the key/salt implicitly by not providing password to unlockVaultAndGetBackupEncKey + await controller.submitPassword(OLD_PASSWORD); // Unlock using the standard method + + const recoverEncKeySpy = jest.spyOn(toprfClient, 'recoverEncKey'); + const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); + + recoverEncKeySpy.mockResolvedValueOnce({ + encKey: newEncKey, + pwEncKey: newPwEncKey, + authKeyPair: newAuthKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + // Lock the wallet + await controller.setLocked(); + + // The following mocks are to simulate the token expiry and refresh. + // mock token expiry + jest + .spyOn(controller, 'checkNodeAuthTokenExpired') + .mockReturnValueOnce(true); + // mock token refresh + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: newerAccessToken, + metadataAccessToken: 'new-metadata-access-token', + }); + // mock toprfClient.authenticate which is called to generate new NodeAuthTokens + jest.spyOn(toprfClient, 'authenticate').mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + // Mock recoverEncKey for the global password + const encKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); + const pwEncKey = mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); + const authKeyPair = + mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); + jest.spyOn(toprfClient, 'recoverEncKey').mockResolvedValueOnce({ + encKey, + pwEncKey, + authKeyPair, + rateLimitResetResult: Promise.resolve(), + keyShareIndex: 1, + }); + + // Mock toprfClient.recoverPwEncKey + const recoveredPwEncKey = + mockToprfEncryptor.derivePwEncKey(OLD_PASSWORD); + jest.spyOn(toprfClient, 'recoverPwEncKey').mockResolvedValueOnce({ + pwEncKey: recoveredPwEncKey, + }); + + await controller.submitGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }); + + // assert that the newer access token is set in the state + expect(controller.state.accessToken).toBe(newerAccessToken); + + await controller.syncLatestGlobalPassword({ + globalPassword: GLOBAL_PASSWORD, + }); + + // Check if vault was re-encrypted with the new password and keys + const expectedSerializedVaultData = JSON.stringify({ + toprfEncryptionKey: bytesToBase64(newEncKey), + toprfPwEncryptionKey: bytesToBase64(newPwEncKey), + toprfAuthKeyPair: JSON.stringify({ + sk: bigIntToHex(newAuthKeyPair.sk), + pk: bytesToBase64(newAuthKeyPair.pk), + }), + revokeToken: controller.state.revokeToken, + accessToken: newerAccessToken, + }); + expect(encryptorSpy).toHaveBeenCalledWith( + GLOBAL_PASSWORD, + expectedSerializedVaultData, + ); + }, + ); + }); + it('should throw an error if recovering the encryption key for the global password fails', async () => { await withController( { @@ -4098,13 +4206,6 @@ describe('SeedlessOnboardingController', () => { const encryptorSpy = jest.spyOn(encryptor, 'encryptWithDetail'); // Make recoverEncKey succeed - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - recoverEncKeySpy.mockResolvedValueOnce({ encKey: newEncKey, pwEncKey: newPwEncKey, @@ -4156,13 +4257,6 @@ describe('SeedlessOnboardingController', () => { async ({ controller, toprfClient }) => { // Here we are creating mock keys associated with the new global password // and these values are used as mock return values for the recoverEncKey and recoverPwEncKey calls - const mockToprfEncryptor = createMockToprfEncryptor(); - const newEncKey = mockToprfEncryptor.deriveEncKey(GLOBAL_PASSWORD); - const newPwEncKey = - mockToprfEncryptor.derivePwEncKey(GLOBAL_PASSWORD); - const newAuthKeyPair = - mockToprfEncryptor.deriveAuthKeyPair(GLOBAL_PASSWORD); - const recoverEncKeySpy = jest .spyOn(toprfClient, 'recoverEncKey') .mockResolvedValueOnce({ @@ -5058,22 +5152,32 @@ describe('SeedlessOnboardingController', () => { }); describe('refreshAuthTokens', () => { - it('should successfully refresh node auth tokens', async () => { - const mockToprfEncryptor = createMockToprfEncryptor(); - const MOCK_ENCRYPTION_KEY = - mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); - const MOCK_PW_ENCRYPTION_KEY = + const mockToprfEncryptor = createMockToprfEncryptor(); + let MOCK_ENCRYPTION_KEY: Uint8Array; + let MOCK_PW_ENCRYPTION_KEY: Uint8Array; + let MOCK_AUTH_KEY_PAIR: KeyPair; + let encryptedMockVault: string; + let vaultEncryptionKey: string; + let vaultEncryptionSalt: string; + + beforeEach(async () => { + MOCK_ENCRYPTION_KEY = mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + MOCK_PW_ENCRYPTION_KEY = mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); - const MOCK_AUTH_KEY_PAIR = + MOCK_AUTH_KEY_PAIR = mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); - const { encryptedMockVault, vaultEncryptionKey, vaultEncryptionSalt } = - await createMockVault( - MOCK_ENCRYPTION_KEY, - MOCK_PW_ENCRYPTION_KEY, - MOCK_AUTH_KEY_PAIR, - MOCK_PASSWORD, - ); + const mockVault = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_PW_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + ); + encryptedMockVault = mockVault.encryptedMockVault; + vaultEncryptionKey = mockVault.vaultEncryptionKey; + vaultEncryptionSalt = mockVault.vaultEncryptionSalt; + }); + it('should successfully refresh node auth tokens', async () => { await withController( { state: getMockInitialControllerState({ @@ -5088,23 +5192,7 @@ describe('SeedlessOnboardingController', () => { // Mock authenticate for token refresh jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ - nodeAuthTokens: [ - { - authToken: 'newAuthToken1', - nodeIndex: 1, - nodePubKey: 'newNodePubKey1', - }, - { - authToken: 'newAuthToken2', - nodeIndex: 2, - nodePubKey: 'newNodePubKey2', - }, - { - authToken: 'newAuthToken3', - nodeIndex: 3, - nodePubKey: 'newNodePubKey3', - }, - ], + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, isNewUser: false, }); @@ -5188,19 +5276,139 @@ describe('SeedlessOnboardingController', () => { }, ); }); + + it('should update accessToken and metadataAccessToken in state after refresh', async () => { + const newAccessToken = 'new-access-token'; + const newMetadataAccessToken = 'new-metadata-access-token'; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ + controller, + toprfClient, + mockRefreshJWTToken, + encryptor, + }) => { + const encryptWithKeySpy = jest.spyOn(encryptor, 'encryptWithKey'); + await controller.submitPassword(MOCK_PASSWORD); + + // Mock refreshJWTToken to return new tokens + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: newAccessToken, + metadataAccessToken: newMetadataAccessToken, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.refreshAuthTokens(); + + // Verify the state is updated with new tokens + expect(controller.state.accessToken).toBe(newAccessToken); + expect(controller.state.metadataAccessToken).toBe( + newMetadataAccessToken, + ); + expect(encryptWithKeySpy).toHaveBeenCalled(); + }, + ); + }); + + it('should store accessToken in state when vault is locked', async () => { + const newAccessToken = 'new-access-token-when-locked'; + const newMetadataAccessToken = 'new-metadata-access-token-when-locked'; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + // Vault is not unlocked (no submitPassword called) + + // Mock refreshJWTToken to return new tokens + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: newAccessToken, + metadataAccessToken: newMetadataAccessToken, + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.refreshAuthTokens(); + + // The accessToken should be stored in state even when vault is locked + expect(controller.state.accessToken).toBe(newAccessToken); + expect(controller.state.metadataAccessToken).toBe( + newMetadataAccessToken, + ); + }, + ); + }); + + it('should throw error when vaultEncryptionKey or vaultEncryptionSalt is missing while vault is unlocked', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + vault: encryptedMockVault, + vaultEncryptionKey, + vaultEncryptionSalt, + }), + }, + async ({ controller, toprfClient, mockRefreshJWTToken }) => { + // Unlock the vault first + await controller.submitPassword(MOCK_PASSWORD); + + // Mock refreshJWTToken to return new tokens + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: 'new-access-token', + metadataAccessToken: 'new-metadata-access-token', + }); + + // Mock authenticate for token refresh + jest.spyOn(toprfClient, 'authenticate').mockResolvedValue({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + // Clear vaultEncryptionKey from state after unlocking + // @ts-expect-error Accessing protected method for testing + controller.update((state) => { + state.vaultEncryptionKey = undefined; + state.vaultEncryptionSalt = undefined; + }); + + // Should throw AuthenticationError (which wraps MissingCredentials) + await expect(controller.refreshAuthTokens()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + }, + ); + }); }); }); describe('fetchMetadataAccessCreds', () => { - const createMockJWTToken = (exp: number): string => { - const payload = { exp }; - const encodedPayload = btoa(JSON.stringify(payload)); - return `header.${encodedPayload}.signature`; - }; - it('should return the current metadata access token if not expired', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const validToken = createMockJWTToken(futureExp); + const validToken = createMockJWTToken({ exp: futureExp }); const { messenger } = mockSeedlessOnboardingMessenger(); const controller = new SeedlessOnboardingController({ @@ -5244,7 +5452,7 @@ describe('SeedlessOnboardingController', () => { it('should call refreshAuthTokens if metadataAccessToken is expired', async () => { const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const expiredToken = createMockJWTToken(pastExp); + const expiredToken = createMockJWTToken({ exp: pastExp }); const { messenger } = mockSeedlessOnboardingMessenger(); const controller = new SeedlessOnboardingController({ messenger, @@ -5268,15 +5476,9 @@ describe('SeedlessOnboardingController', () => { }); describe('checkMetadataAccessTokenExpired', () => { - const createMockJWTToken = (exp: number): string => { - const payload = { exp }; - const encodedPayload = btoa(JSON.stringify(payload)); - return `header.${encodedPayload}.signature`; - }; - it('should return false if metadata access token is not expired', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const validToken = createMockJWTToken(futureExp); + const validToken = createMockJWTToken({ exp: futureExp }); await withController( { @@ -5299,7 +5501,7 @@ describe('SeedlessOnboardingController', () => { it('should return true if metadata access token is expired', async () => { const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const expiredToken = createMockJWTToken(pastExp); + const expiredToken = createMockJWTToken({ exp: pastExp }); await withController( { @@ -5352,15 +5554,9 @@ describe('SeedlessOnboardingController', () => { }); describe('checkAccessTokenExpired', () => { - const createMockJWTToken = (exp: number): string => { - const payload = { exp }; - const encodedPayload = btoa(JSON.stringify(payload)); - return `header.${encodedPayload}.signature`; - }; - it('should return false if access token is not expired', async () => { const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now - const validToken = createMockJWTToken(futureExp); + const validToken = createMockJWTToken({ exp: futureExp }); await withController( { @@ -5381,7 +5577,7 @@ describe('SeedlessOnboardingController', () => { it('should return true if access token is expired', async () => { const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago - const expiredToken = createMockJWTToken(pastExp); + const expiredToken = createMockJWTToken({ exp: pastExp }); await withController( { @@ -5447,6 +5643,168 @@ describe('SeedlessOnboardingController', () => { }); }); + describe('getAccessToken', () => { + const MOCK_ACCESS_TOKEN = 'mock-access-token'; + const MOCK_PASSWORD = 'mock-password'; + let MOCK_VAULT: string; + let MOCK_VAULT_ENCRYPTION_KEY: string; + let MOCK_VAULT_ENCRYPTION_SALT: string; + + beforeAll(async () => { + const mockToprfEncryptor = createMockToprfEncryptor(); + const MOCK_ENCRYPTION_KEY = + mockToprfEncryptor.deriveEncKey(MOCK_PASSWORD); + const MOCK_PASSWORD_ENCRYPTION_KEY = + mockToprfEncryptor.derivePwEncKey(MOCK_PASSWORD); + const MOCK_AUTH_KEY_PAIR = + mockToprfEncryptor.deriveAuthKeyPair(MOCK_PASSWORD); + + const mockResult = await createMockVault( + MOCK_ENCRYPTION_KEY, + MOCK_PASSWORD_ENCRYPTION_KEY, + MOCK_AUTH_KEY_PAIR, + MOCK_PASSWORD, + revokeToken, + MOCK_ACCESS_TOKEN, + ); + + MOCK_VAULT = mockResult.encryptedMockVault; + MOCK_VAULT_ENCRYPTION_KEY = mockResult.vaultEncryptionKey; + MOCK_VAULT_ENCRYPTION_SALT = mockResult.vaultEncryptionSalt; + }); + + it('should return the access token', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + accessToken: MOCK_ACCESS_TOKEN, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.getAccessToken(); + expect(result).toBe(MOCK_ACCESS_TOKEN); + }, + ); + }); + + it('should return undefined when accessToken is not set', async () => { + await withController( + { + state: { + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + metadataAccessToken, + userId, + authConnectionId, + groupedAuthConnectionId, + authConnection, + refreshToken, + }, + }, + async ({ controller }) => { + const result = await controller.getAccessToken(); + expect(result).toBeUndefined(); + }, + ); + }); + + it('should throw error when user is not authenticated', async () => { + await withController( + { + state: {}, + }, + async ({ controller }) => { + await expect(controller.getAccessToken()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.MissingAuthUserInfo, + ); + }, + ); + }); + + it('should throw error when authentication fails', async () => { + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + withoutMockAccessToken: true, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller }) => { + jest + .spyOn(controller, 'checkNodeAuthTokenExpired') + .mockReturnValueOnce(true); + jest + .spyOn(controller, 'authenticate') + .mockRejectedValueOnce( + new Error( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ), + ); + + await expect(controller.getAccessToken()).rejects.toThrow( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + }, + ); + }); + + it('should refresh tokens if expired and return the new access token', async () => { + // Create an expired JWT token + const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const payload = { exp: pastExp }; + const encodedPayload = btoa(JSON.stringify(payload)); + const expiredAccessToken = `header.${encodedPayload}.signature`; + + const NEW_ACCESS_TOKEN = 'new-access-token'; + + await withController( + { + state: getMockInitialControllerState({ + withMockAuthenticatedUser: true, + accessToken: expiredAccessToken, + vault: MOCK_VAULT, + vaultEncryptionKey: MOCK_VAULT_ENCRYPTION_KEY, + vaultEncryptionSalt: MOCK_VAULT_ENCRYPTION_SALT, + }), + }, + async ({ controller, mockRefreshJWTToken, toprfClient }) => { + jest + .spyOn(controller, 'checkAccessTokenExpired') + .mockReturnValueOnce(true); + + // Mock the token refresh to return a new access token + mockRefreshJWTToken.mockResolvedValueOnce({ + idTokens: ['newIdToken'], + accessToken: NEW_ACCESS_TOKEN, + metadataAccessToken: 'newMetadataAccessToken', + }); + + const authenticateSpy = jest + .spyOn(toprfClient, 'authenticate') + .mockResolvedValueOnce({ + nodeAuthTokens: MOCK_NODE_AUTH_TOKENS, + isNewUser: false, + }); + + await controller.submitPassword(MOCK_PASSWORD); + + const result = await controller.getAccessToken(); + expect(result).toBe(NEW_ACCESS_TOKEN); + expect(mockRefreshJWTToken).toHaveBeenCalled(); + expect(authenticateSpy).toHaveBeenCalled(); + }, + ); + }); + }); + describe('#getAccessTokenAndRevokeToken', () => { const MOCK_PASSWORD = 'mock-password'; @@ -5777,12 +6135,12 @@ describe('SeedlessOnboardingController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { + { "authConnection": "google", "authConnectionId": "authConnectionId", "groupedAuthConnectionId": "groupedAuthConnectionId", "isSeedlessOnboardingUserAuthenticated": false, - "passwordOutdatedCache": Object { + "passwordOutdatedCache": { "isExpiredPwd": false, "timestamp": 1234567890, }, @@ -5831,14 +6189,14 @@ describe('SeedlessOnboardingController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { + { "authConnection": "google", "authConnectionId": "authConnectionId", "authPubKey": "authPubKey", "groupedAuthConnectionId": "groupedAuthConnectionId", "isSeedlessOnboardingUserAuthenticated": false, "nodeAuthTokens": true, - "passwordOutdatedCache": Object { + "passwordOutdatedCache": { "isExpiredPwd": false, "timestamp": 1234567890, }, @@ -5888,7 +6246,7 @@ describe('SeedlessOnboardingController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { + { "authConnection": "google", "authConnectionId": "authConnectionId", "authPubKey": "authPubKey", @@ -5897,19 +6255,19 @@ describe('SeedlessOnboardingController', () => { "groupedAuthConnectionId": "groupedAuthConnectionId", "isSeedlessOnboardingUserAuthenticated": false, "metadataAccessToken": "metadataAccessToken", - "nodeAuthTokens": Array [], - "passwordOutdatedCache": Object { + "nodeAuthTokens": [], + "passwordOutdatedCache": { "isExpiredPwd": false, "timestamp": 1234567890, }, - "pendingToBeRevokedTokens": Array [ - Object { + "pendingToBeRevokedTokens": [ + { "refreshToken": "refreshToken", "revokeToken": "revokeToken", }, ], "refreshToken": "refreshToken", - "socialBackupsMetadata": Array [], + "socialBackupsMetadata": [], "socialLoginEmail": "socialLoginEmail", "userId": "userId", "vault": "vault", @@ -5958,11 +6316,11 @@ describe('SeedlessOnboardingController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "authConnection": "google", - "socialLoginEmail": "socialLoginEmail", - } - `); + { + "authConnection": "google", + "socialLoginEmail": "socialLoginEmail", + } + `); }, ); }); diff --git a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts index 093e77ac361..115ef952b81 100644 --- a/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts +++ b/packages/seedless-onboarding-controller/src/SeedlessOnboardingController.ts @@ -1,7 +1,12 @@ import { keccak256AndHexify } from '@metamask/auth-network-utils'; import { BaseController } from '@metamask/base-controller'; -import type { StateMetadata } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; import type * as encryptionUtils from '@metamask/browser-passworder'; +import type { Messenger } from '@metamask/messenger'; import type { AuthenticateResult, ChangeEncryptionKeyResult, @@ -28,6 +33,7 @@ import { Mutex } from 'async-mutex'; import { assertIsPasswordOutdatedCacheValid, assertIsSeedlessOnboardingUserAuthenticated, + assertIsValidPassword, assertIsValidVaultData, } from './assertions'; import type { AuthConnection } from './constants'; @@ -38,13 +44,15 @@ import { SeedlessOnboardingControllerErrorMessage, Web3AuthNetwork, } from './constants'; -import { PasswordSyncError, RecoveryError } from './errors'; +import { + PasswordSyncError, + RecoveryError, + SeedlessOnboardingError, +} from './errors'; import { projectLogger, createModuleLogger } from './logger'; import { SecretMetadata } from './SecretMetadata'; import type { MutuallyExclusiveCallback, - SeedlessOnboardingControllerMessenger, - SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerState, AuthenticatedUserDetails, SocialBackupsMetadata, @@ -54,8 +62,10 @@ import type { RenewRefreshToken, VaultData, DeserializedVaultData, + ToprfKeyDeriver, } from './types'; import { + compareAndGetLatestToken, decodeJWTToken, decodeNodeAuthToken, deserializeVaultData, @@ -64,6 +74,117 @@ import { const log = createModuleLogger(projectLogger, controllerName); +// Actions +export type SeedlessOnboardingControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + SeedlessOnboardingControllerState + >; + +/** + * Get the access token from the controller. + * If the tokens are expired, the method will refresh them and return the new access token. + * + * @returns The access token. + */ +export type SeedlessOnboardingControllerGetAccessTokenAction = { + type: `${typeof controllerName}:getAccessToken`; + handler: SeedlessOnboardingController< + encryptionUtils.EncryptionKey, + encryptionUtils.KeyDerivationOptions + >['getAccessToken']; +}; +export type SeedlessOnboardingControllerActions = + | SeedlessOnboardingControllerGetStateAction + | SeedlessOnboardingControllerGetAccessTokenAction; + +type AllowedActions = never; + +// Events +export type SeedlessOnboardingControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + SeedlessOnboardingControllerState + >; +export type SeedlessOnboardingControllerEvents = + SeedlessOnboardingControllerStateChangeEvent; + +type AllowedEvents = never; + +// Messenger +export type SeedlessOnboardingControllerMessenger = Messenger< + typeof controllerName, + SeedlessOnboardingControllerActions | AllowedActions, + SeedlessOnboardingControllerEvents | AllowedEvents +>; + +/** + * Seedless Onboarding Controller Options. + * + * @param messenger - The messenger to use for this controller. + * @param state - The initial state to set on this controller. + * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. + */ +export type SeedlessOnboardingControllerOptions< + EncryptionKey, + SupportedKeyDerivationParams, +> = { + messenger: SeedlessOnboardingControllerMessenger; + + /** + * Initial state to set on this controller. + */ + state?: Partial; + + /** + * Encryptor to use for encrypting and decrypting seedless onboarding vault. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + encryptor: VaultEncryptor; + + /** + * A function to get a new jwt token using refresh token. + */ + refreshJWTToken: RefreshJWTToken; + + /** + * A function to revoke the refresh token. + */ + revokeRefreshToken: RevokeRefreshToken; + + /** + * A function to renew the refresh token and get new revoke token. + */ + renewRefreshToken: RenewRefreshToken; + + /** + * Optional key derivation interface for the TOPRF client. + * + * If provided, it will be used as an additional step during + * key derivation. This can be used, for example, to inject a slow key + * derivation step to protect against local brute force attacks on the + * password. + * + * @default browser-passworder @link https://github.com/MetaMask/browser-passworder + */ + toprfKeyDeriver?: ToprfKeyDeriver; + + /** + * Type of Web3Auth network to be used for the Seedless Onboarding flow. + * + * @default Web3AuthNetwork.Mainnet + */ + network?: Web3AuthNetwork; + + /** + * The TTL of the password outdated cache in milliseconds. + * + * @default PASSWORD_OUTDATED_CACHE_TTL_MS + */ + passwordOutdatedCacheTTL?: number; +}; + /** * Get the initial state for the Seedless Onboarding Controller with defaults. * @@ -318,6 +439,11 @@ export class SeedlessOnboardingController< this.#refreshJWTToken = refreshJWTToken; this.#revokeRefreshToken = revokeRefreshToken; this.#renewRefreshToken = renewRefreshToken; + + this.messenger.registerActionHandler( + `${controllerName}:getAccessToken`, + this.getAccessToken.bind(this), + ); } async fetchMetadataAccessCreds(): Promise<{ @@ -436,8 +562,11 @@ export class SeedlessOnboardingController< return authenticationResult; } catch (error) { log('Error authenticating user', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.AuthenticationError, + { + cause: error, + }, ); } }; @@ -663,8 +792,11 @@ export class SeedlessOnboardingController< ); } catch (error) { log('Error changing password', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToChangePassword, + { + cause: error, + }, ); } }); @@ -735,7 +867,7 @@ export class SeedlessOnboardingController< /** * Submit the password to the controller, verify the password validity and unlock the controller. * - * This method will be used especially when user rehydrate/unlock the wallet. + * This method will be used especially when user unlock the wallet. * The provided password will be verified against the encrypted vault, encryption key will be derived and saved in the controller state. * * This operation is useful when user performs some actions that requires the user password/encryption key. e.g. add new srp backup @@ -745,7 +877,36 @@ export class SeedlessOnboardingController< */ async submitPassword(password: string): Promise { return await this.#withControllerLock(async () => { - await this.#unlockVaultAndGetVaultData({ password }); + // get the access token from the state before unlocking, it might be the new token set from the `refreshAuthTokens` method. + const { accessToken: accessTokenBeforeUnlock } = this.state; + + const deserializedVaultData = await this.#unlockVaultAndGetVaultData({ + password, + }); + + const accessTokenFromDecryptedVault = deserializedVaultData.accessToken; + + // Pick the latest access token - the token from state might be newer (from refreshAuthTokens) + // than the token stored in the vault. + const latestAccessToken = this.#pickLatestAccessToken( + accessTokenBeforeUnlock, + accessTokenFromDecryptedVault, + ); + + // update the state and vault with the latest access token `ONLY` if it's different from the current access token in the state. + if (latestAccessToken !== accessTokenFromDecryptedVault) { + const updatedVaultData = { + ...deserializedVaultData, + accessToken: latestAccessToken, + }; + + await this.#updateVault({ + password, + vaultData: updatedVaultData, + pwEncKey: deserializedVaultData.toprfPwEncryptionKey, + }); + } + this.#setUnlocked(); }); } @@ -856,25 +1017,36 @@ export class SeedlessOnboardingController< globalPassword: string; maxKeyChainLength: number; }): Promise { - const { pwEncKey: curPwEncKey, authKeyPair: curAuthKeyPair } = + const { pwEncKey: globalPwEncKey, authKeyPair: globalAuthKeyPair } = await this.#recoverEncKey(globalPassword); try { // Recover vault encryption key. const res = await this.toprfClient.recoverPwEncKey({ targetAuthPubKey, - curPwEncKey, - curAuthKeyPair, + curPwEncKey: globalPwEncKey, + curAuthKeyPair: globalAuthKeyPair, maxPwChainLength: maxKeyChainLength, }); const { pwEncKey } = res; const vaultKey = await this.#loadSeedlessEncryptionKey(pwEncKey); + // accessToken before unlocking vault and flooding the state with values from the decrypted vault + // it might be the new token set from the `refreshAuthTokens` method. + const { accessToken: accessTokenBeforeUnlock } = this.state; + // Unlock the controller - await this.#unlockVaultAndGetVaultData({ + const decryptedVaultData = await this.#unlockVaultAndGetVaultData({ encryptionKey: vaultKey, }); this.#setUnlocked(); + + // Pick the latest access token - the token from state might be newer (from refreshAuthTokens) + // than the token stored in the vault. The vault will be updated later by syncLatestGlobalPassword. + this.#pickLatestAccessToken( + accessTokenBeforeUnlock, + decryptedVaultData.accessToken, + ); } catch (error) { if (this.#isAuthTokenError(error)) { throw error; @@ -940,8 +1112,11 @@ export class SeedlessOnboardingController< }) .catch((error) => { log('Error fetching auth pub key', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToFetchAuthPubKey, + { + cause: error, + }, ); }); globalAuthPubKey = authPubKey; @@ -991,6 +1166,39 @@ export class SeedlessOnboardingController< this.#isUnlocked = true; } + /** + * Compares two access tokens and picks the latest one based on JWT expiration. + * If the tokens are different, the state is updated with the latest token. + * + * @param tokenBeforeUnlock - The access token from state before unlocking (may have been set by refreshAuthTokens). + * @param tokenAfterUnlock - The access token from the decrypted vault after unlocking. + * @returns The latest access token, or the token after unlock if no reconciliation was needed. + */ + #pickLatestAccessToken( + tokenBeforeUnlock: string | undefined, + tokenAfterUnlock: string, + ): string { + let latestToken = tokenAfterUnlock; + + if ( + tokenBeforeUnlock && + tokenAfterUnlock && + tokenBeforeUnlock !== tokenAfterUnlock + ) { + latestToken = compareAndGetLatestToken( + tokenBeforeUnlock, + tokenAfterUnlock, + ); + + // Update the access token in the state with the latest access token + this.update((state) => { + state.accessToken = latestToken; + }); + } + + return latestToken; + } + /** * Clears the current state of the SeedlessOnboardingController. */ @@ -1030,8 +1238,11 @@ export class SeedlessOnboardingController< throw error; } log('Error persisting local encryption key', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToPersistOprfKey, + { + cause: error, + }, ); } } @@ -1194,8 +1405,11 @@ export class SeedlessOnboardingController< if (this.#isAuthTokenError(error)) { throw error; } - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToFetchSecretMetadata, + { + cause: error, + }, ); } @@ -1593,7 +1807,7 @@ export class SeedlessOnboardingController< * Encrypt and update the vault with the given authentication data. * * @param params - The parameters for updating the vault. - * @param params.password - The password to encrypt the vault. + * @param params.password - The optional password to encrypt the vault. If not provided, the vault will be encrypted with the encryption key in the state. * @param params.vaultData - The raw vault data to update the vault with. * @param params.pwEncKey - The global password encryption key. * @returns A promise that resolves to the updated vault. @@ -1603,40 +1817,97 @@ export class SeedlessOnboardingController< vaultData, pwEncKey, }: { - password: string; + password?: string; vaultData: DeserializedVaultData; pwEncKey: Uint8Array; }): Promise { await this.#withVaultLock(async () => { - assertIsValidPassword(password); + const serializedVaultData = serializeVaultData(vaultData); - // cache the vault data to avoid decrypting the vault data multiple times - this.#cachedDecryptedVaultData = vaultData; + const { vaultEncryptionKey, vaultEncryptionSalt, vault } = this.state; - const serializedVaultData = serializeVaultData(vaultData); + const updatedState: Partial = { + vault, + vaultEncryptionKey, + vaultEncryptionSalt, + encryptedSeedlessEncryptionKey: + this.state.encryptedSeedlessEncryptionKey, + }; - // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key - // from the password using an intentionally slow key derivation function. - // We should make sure that we only call it very intentionally. - const { vault, exportedKeyString } = - await this.#vaultEncryptor.encryptWithDetail( - password, + // if the password is provided (not undefined), encrypt the vault with the password + // We gonna prioritize the password encryption here, in case of the operation is `Change Password`. + // We don't wanna re-use the old encryption key from the state. + if (password !== undefined) { + assertIsValidPassword(password); + + // Note that vault encryption using the password is a very costly operation as it involves deriving the encryption key + // from the password using an intentionally slow key derivation function. + // We should make sure that we only call it very intentionally. + const { vault: updatedEncVault, exportedKeyString } = + await this.#vaultEncryptor.encryptWithDetail( + password, + serializedVaultData, + ); + + updatedState.vault = updatedEncVault; + updatedState.vaultEncryptionKey = exportedKeyString; + updatedState.vaultEncryptionSalt = JSON.parse(updatedEncVault).salt; + + // encrypt the seedless encryption key with the password encryption key from TOPRF network + updatedState.encryptedSeedlessEncryptionKey = + this.#encryptSeedlessEncryptionKey(exportedKeyString, pwEncKey); + } else if (vaultEncryptionKey && vaultEncryptionSalt) { + const encryptionKey = + await this.#vaultEncryptor.importKey(vaultEncryptionKey); + const updatedEncVault = await this.#vaultEncryptor.encryptWithKey( + encryptionKey, serializedVaultData, ); - // Encrypt vault key. - const aes = managedNonce(gcm)(pwEncKey); - const encryptedKey = aes.encrypt(utf8ToBytes(exportedKeyString)); + // NOTE: Referenced from keyring-controller! + // We need to include the salt used to derive the encryption key, to be able to derive it from password again. + updatedEncVault.salt = vaultEncryptionSalt; + updatedState.vault = JSON.stringify(updatedEncVault); + updatedState.vaultEncryptionKey = vaultEncryptionKey; + updatedState.vaultEncryptionSalt = vaultEncryptionSalt; + } else { + // neither password nor encryption key is provided + throw new Error( + SeedlessOnboardingControllerErrorMessage.MissingCredentials, + ); + } + + // update the state with the updated vault data this.update((state) => { - state.vault = vault; - state.vaultEncryptionKey = exportedKeyString; - state.vaultEncryptionSalt = JSON.parse(vault).salt; - state.encryptedSeedlessEncryptionKey = bytesToBase64(encryptedKey); + state.vault = updatedState.vault; + state.vaultEncryptionKey = updatedState.vaultEncryptionKey; + state.vaultEncryptionSalt = updatedState.vaultEncryptionSalt; + state.encryptedSeedlessEncryptionKey = + updatedState.encryptedSeedlessEncryptionKey; }); + + // cache the vault data to avoid decrypting the vault data multiple times + this.#cachedDecryptedVaultData = vaultData; }); } + /** + * Encrypt the seedless encryption key with the password encryption key from TOPRF network. + * + * @param vaultEncryptionKey - The key which is used to encrypt the vault. + * @param pwEncKey - The password encryption key from TOPRF network. + * @returns The encrypted seedless encryption key. + */ + #encryptSeedlessEncryptionKey( + vaultEncryptionKey: string, + pwEncKey: Uint8Array, + ): string { + const aes = managedNonce(gcm)(pwEncKey); + const encryptedKey = aes.encrypt(utf8ToBytes(vaultEncryptionKey)); + return bytesToBase64(encryptedKey); + } + /** * Get the access token and revoke token from the state or the vault. * @@ -1804,8 +2075,11 @@ export class SeedlessOnboardingController< }) .catch((error) => { log('Error fetching auth pub key', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToFetchAuthPubKey, + { + cause: error, + }, ); }); const isPasswordOutdated = await this.checkIsPasswordOutdated({ @@ -1843,8 +2117,11 @@ export class SeedlessOnboardingController< refreshToken, }).catch((error) => { log('Error refreshing JWT tokens', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.FailedToRefreshJWTTokens, + { + cause: error, + }, ); }); @@ -1863,10 +2140,27 @@ export class SeedlessOnboardingController< refreshToken, skipLock: true, }); + + // update the vault with new access token if wallet is unlocked + if (this.#isUnlocked && this.#cachedDecryptedVaultData) { + const updatedVaultData = { + ...this.#cachedDecryptedVaultData, + accessToken, + }; + const pwEncKey = this.#cachedDecryptedVaultData.toprfPwEncryptionKey; + + await this.#updateVault({ + vaultData: updatedVaultData, + pwEncKey, + }); + } } catch (error) { log('Error refreshing node auth tokens', error); - throw new Error( + throw new SeedlessOnboardingError( SeedlessOnboardingControllerErrorMessage.AuthenticationError, + { + cause: error, + }, ); } } @@ -1970,6 +2264,23 @@ export class SeedlessOnboardingController< }); } + /** + * Get the access token from the state. + * + * If the tokens are expired, the method will refresh them and return the new access token. + * + * @returns The access token. + */ + async getAccessToken(): Promise { + return this.#withControllerLock(async () => { + this.#assertIsAuthenticatedUser(this.state); + + return this.#executeWithTokenRefresh(async () => { + return this.state.accessToken; + }, 'getAccessToken'); + }); + } + /** * Add a pending refresh, revoke token to the state to be revoked later. * @@ -2047,22 +2358,7 @@ export class SeedlessOnboardingController< operationName: string, ): Promise { try { - // proactively check for expired tokens and refresh them if needed - const isNodeAuthTokenExpired = this.checkNodeAuthTokenExpired(); - const isMetadataAccessTokenExpired = - this.checkMetadataAccessTokenExpired(); - // access token is only accessible when the vault is unlocked - // so skip the check if the vault is locked - let isAccessTokenExpired = false; - if (this.#isUnlocked) { - isAccessTokenExpired = this.checkAccessTokenExpired(); - } - - if ( - isNodeAuthTokenExpired || - isMetadataAccessTokenExpired || - isAccessTokenExpired - ) { + if (this.#checkTokensExpired()) { log( `JWT token expired during ${operationName}, attempting to refresh tokens`, 'node auth token exp check', @@ -2094,6 +2390,29 @@ export class SeedlessOnboardingController< } } + /** + * Check if the tokens are expired. + * + * @returns True if the tokens are expired, false otherwise. + */ + #checkTokensExpired(): boolean { + // proactively check for expired tokens and refresh them if needed + const isNodeAuthTokenExpired = this.checkNodeAuthTokenExpired(); + const isMetadataAccessTokenExpired = this.checkMetadataAccessTokenExpired(); + // access token is only accessible when the vault is unlocked + // so skip the check if the vault is locked + let isAccessTokenExpired = false; + if (this.#isUnlocked) { + isAccessTokenExpired = this.checkAccessTokenExpired(); + } + + return ( + isNodeAuthTokenExpired || + isMetadataAccessTokenExpired || + isAccessTokenExpired + ); + } + /** * Check if the current node auth token is expired. * @@ -2149,24 +2468,6 @@ export class SeedlessOnboardingController< } } -/** - * Assert that the provided password is a valid non-empty string. - * - * @param password - The password to check. - * @throws If the password is not a valid string. - */ -function assertIsValidPassword(password: unknown): asserts password is string { - if (typeof password !== 'string') { - throw new Error(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); - } - - if (!password?.length) { - throw new Error( - SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, - ); - } -} - /** * Lock the given mutex before executing the given function, * and release it after the function is resolved or after an diff --git a/packages/seedless-onboarding-controller/src/assertions.test.ts b/packages/seedless-onboarding-controller/src/assertions.test.ts index 7a36a7c8a02..270f53adc2f 100644 --- a/packages/seedless-onboarding-controller/src/assertions.test.ts +++ b/packages/seedless-onboarding-controller/src/assertions.test.ts @@ -1,10 +1,47 @@ import { assertIsPasswordOutdatedCacheValid, + assertIsValidPassword, assertIsValidVaultData, } from './assertions'; import { SeedlessOnboardingControllerErrorMessage } from './constants'; import { VaultData } from './types'; +describe('assertIsValidPassword', () => { + it('should throw when password is not a string', () => { + expect(() => { + assertIsValidPassword(null); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword(undefined); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword(123); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword({}); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + + expect(() => { + assertIsValidPassword([]); + }).toThrow(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + }); + + it('should throw when password is an empty string', () => { + expect(() => { + assertIsValidPassword(''); + }).toThrow(SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword); + }); + + it('should not throw for valid non-empty string', () => { + expect(() => { + assertIsValidPassword('password123'); + }).not.toThrow(); + }); +}); + describe('assertIsValidVaultData', () => { /** * Helper function to create valid vault data for testing diff --git a/packages/seedless-onboarding-controller/src/assertions.ts b/packages/seedless-onboarding-controller/src/assertions.ts index e8a35539340..c9929e1a2b4 100644 --- a/packages/seedless-onboarding-controller/src/assertions.ts +++ b/packages/seedless-onboarding-controller/src/assertions.ts @@ -1,6 +1,26 @@ import { SeedlessOnboardingControllerErrorMessage } from './constants'; import type { AuthenticatedUserDetails, VaultData } from './types'; +/** + * Assert that the provided password is a valid non-empty string. + * + * @param password - The password to check. + * @throws If the password is not a valid string. + */ +export function assertIsValidPassword( + password: unknown, +): asserts password is string { + if (typeof password !== 'string') { + throw new Error(SeedlessOnboardingControllerErrorMessage.WrongPasswordType); + } + + if (!password?.length) { + throw new Error( + SeedlessOnboardingControllerErrorMessage.InvalidEmptyPassword, + ); + } +} + /** * Check if the provided value is a valid authenticated user. * diff --git a/packages/seedless-onboarding-controller/src/errors.test.ts b/packages/seedless-onboarding-controller/src/errors.test.ts index 0011a44c87f..e090e16a648 100644 --- a/packages/seedless-onboarding-controller/src/errors.test.ts +++ b/packages/seedless-onboarding-controller/src/errors.test.ts @@ -1,7 +1,10 @@ import { TOPRFErrorCode } from '@metamask/toprf-secure-backup'; import { SeedlessOnboardingControllerErrorMessage } from './constants'; -import { getErrorMessageFromTOPRFErrorCode } from './errors'; +import { + getErrorMessageFromTOPRFErrorCode, + SeedlessOnboardingError, +} from './errors'; describe('getErrorMessageFromTOPRFErrorCode', () => { it('returns TooManyLoginAttempts for RateLimitExceeded', () => { @@ -49,3 +52,205 @@ describe('getErrorMessageFromTOPRFErrorCode', () => { ).toBe('fallback'); }); }); + +describe('SeedlessOnboardingError', () => { + describe('constructor', () => { + it('creates an error with just a message', () => { + const error = new SeedlessOnboardingError('Test error message'); + + expect(error.message).toBe('Test error message'); + expect(error.name).toBe('SeedlessOnboardingControllerError'); + expect(error.details).toBeUndefined(); + expect(error.cause).toBeUndefined(); + }); + + it('creates an error with a message from SeedlessOnboardingControllerErrorMessage enum', () => { + const error = new SeedlessOnboardingError( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + + expect(error.message).toBe( + SeedlessOnboardingControllerErrorMessage.AuthenticationError, + ); + expect(error.name).toBe('SeedlessOnboardingControllerError'); + }); + + it('creates an error with message and details', () => { + const error = new SeedlessOnboardingError('Test error', { + details: 'Additional context for debugging', + }); + + expect(error.message).toBe('Test error'); + expect(error.details).toBe('Additional context for debugging'); + expect(error.cause).toBeUndefined(); + }); + + it('creates an error with an Error instance as cause', () => { + const originalError = new Error('Original error'); + const error = new SeedlessOnboardingError('Wrapped error', { + cause: originalError, + }); + + expect(error.message).toBe('Wrapped error'); + expect(error.cause).toBe(originalError); + }); + + it('creates an error with a string as cause', () => { + const error = new SeedlessOnboardingError('Test error', { + cause: 'String cause message', + }); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe('String cause message'); + }); + + it('creates an error with an object as cause (JSON serializable)', () => { + const causeObject = { code: 500, reason: 'Internal error' }; + const error = new SeedlessOnboardingError('Test error', { + cause: causeObject, + }); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe(JSON.stringify(causeObject)); + }); + + it('handles circular object as cause by using fallback message', () => { + const circularObject: Record = { name: 'circular' }; + circularObject.self = circularObject; + + const error = new SeedlessOnboardingError('Test error', { + cause: circularObject, + }); + + expect(error.cause).toBeInstanceOf(Error); + expect(error.cause?.message).toBe('Unknown error'); + }); + + it('creates an error with both details and cause', () => { + const originalError = new Error('Original'); + const error = new SeedlessOnboardingError('Test error', { + details: 'Some details', + cause: originalError, + }); + + expect(error.message).toBe('Test error'); + expect(error.details).toBe('Some details'); + expect(error.cause).toBe(originalError); + }); + }); + + describe('toJSON', () => { + it('serializes error with all properties', () => { + const originalError = new Error('Original error'); + const error = new SeedlessOnboardingError('Test error', { + details: 'Debug info', + cause: originalError, + }); + + const json = error.toJSON(); + + expect(json.name).toBe('SeedlessOnboardingControllerError'); + expect(json.message).toBe('Test error'); + expect(json.details).toBe('Debug info'); + expect(json.cause).toStrictEqual({ + name: 'Error', + message: 'Original error', + }); + expect(json.stack).toBeDefined(); + }); + + it('serializes error without optional properties', () => { + const error = new SeedlessOnboardingError('Simple error'); + + const json = error.toJSON(); + + expect(json.name).toBe('SeedlessOnboardingControllerError'); + expect(json.message).toBe('Simple error'); + expect(json.details).toBeUndefined(); + expect(json.cause).toBeUndefined(); + expect(json.stack).toBeDefined(); + }); + + it('serializes error with custom error type as cause', () => { + class CustomError extends Error { + constructor() { + super('Custom error message'); + this.name = 'CustomError'; + } + } + const customError = new CustomError(); + const error = new SeedlessOnboardingError('Wrapper', { + cause: customError, + }); + + const json = error.toJSON(); + + expect(json.cause).toStrictEqual({ + name: 'CustomError', + message: 'Custom error message', + }); + }); + + it('serializes SeedlessOnboardingError cause with details preserved', () => { + const innerError = new SeedlessOnboardingError('Inner error', { + details: 'Inner debugging context', + }); + const outerError = new SeedlessOnboardingError('Outer error', { + details: 'Outer debugging context', + cause: innerError, + }); + + const json = outerError.toJSON(); + + expect(json.name).toBe('SeedlessOnboardingControllerError'); + expect(json.message).toBe('Outer error'); + expect(json.details).toBe('Outer debugging context'); + expect(json.cause).toStrictEqual({ + name: 'SeedlessOnboardingControllerError', + message: 'Inner error', + details: 'Inner debugging context', + cause: undefined, + stack: innerError.stack, + }); + }); + + it('serializes deeply nested SeedlessOnboardingError chain', () => { + const rootError = new Error('Root cause'); + const level1 = new SeedlessOnboardingError('Level 1', { + details: 'Level 1 details', + cause: rootError, + }); + const level2 = new SeedlessOnboardingError('Level 2', { + details: 'Level 2 details', + cause: level1, + }); + + const json = level2.toJSON(); + + expect(json.details).toBe('Level 2 details'); + const level1Json = json.cause as Record; + expect(level1Json.message).toBe('Level 1'); + expect(level1Json.details).toBe('Level 1 details'); + expect(level1Json.cause).toStrictEqual({ + name: 'Error', + message: 'Root cause', + }); + }); + }); + + describe('inheritance', () => { + it('is an instance of Error', () => { + const error = new SeedlessOnboardingError('Test'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(SeedlessOnboardingError); + }); + + it('has a proper stack trace', () => { + const error = new SeedlessOnboardingError('Test'); + + expect(error.stack).toBeDefined(); + expect(error.stack).toContain('SeedlessOnboardingControllerError'); + }); + }); +}); diff --git a/packages/seedless-onboarding-controller/src/errors.ts b/packages/seedless-onboarding-controller/src/errors.ts index 7f5c0224de8..005a614cdb6 100644 --- a/packages/seedless-onboarding-controller/src/errors.ts +++ b/packages/seedless-onboarding-controller/src/errors.ts @@ -135,3 +135,87 @@ export class RecoveryError extends Error { return new RecoveryError(errorMessage, recoveryErrorData); } } + +/** + * Generic error class for SeedlessOnboardingController operations. + * + * Use this when you need to wrap an underlying error with additional context, + * or when none of the more specific error classes (PasswordSyncError, RecoveryError) apply. + * + * @example + * ```typescript + * throw new SeedlessOnboardingError( + * SeedlessOnboardingControllerErrorMessage.FailedToEncryptAndStoreSecretData, + * { details: 'Encryption failed during backup', cause: originalError } + * ); + * ``` + */ +export class SeedlessOnboardingError extends Error { + /** + * Additional context about the error beyond the message. + * Use this for human-readable details that help with debugging. + */ + public details: string | undefined; + + /** + * The underlying error that caused this error. + */ + public cause: Error | undefined; + + constructor( + message: string | SeedlessOnboardingControllerErrorMessage, + options?: { details?: string; cause?: unknown }, + ) { + super(message); + this.name = 'SeedlessOnboardingControllerError'; + this.details = options?.details; + if (options?.cause) { + if (options.cause instanceof Error) { + this.cause = options.cause; + } else { + let causeMessage: string; + if (typeof options.cause === 'string') { + causeMessage = options.cause; + } else { + try { + causeMessage = JSON.stringify(options.cause); + } catch { + causeMessage = 'Unknown error'; + } + } + this.cause = new Error(causeMessage); + } + } + } + + /** + * Serializes the cause error for JSON output. + * + * @returns A JSON-serializable representation of the cause. + */ + #serializeCause(): Record | undefined { + if (this.cause instanceof SeedlessOnboardingError) { + return this.cause.toJSON(); + } + if (this.cause instanceof Error) { + return { name: this.cause.name, message: this.cause.message }; + } + return undefined; + } + + /** + * Serializes the error for logging/transmission. + * Ensures custom properties are included in JSON output. + * + * @returns A JSON-serializable representation of the error. + */ + toJSON(): Record { + return { + name: this.name, + message: this.message, + details: this.details, + cause: this.#serializeCause(), + stack: this.stack, + }; + } +} diff --git a/packages/seedless-onboarding-controller/src/index.ts b/packages/seedless-onboarding-controller/src/index.ts index 4d445795530..09ea04b9e54 100644 --- a/packages/seedless-onboarding-controller/src/index.ts +++ b/packages/seedless-onboarding-controller/src/index.ts @@ -3,15 +3,18 @@ export { getInitialSeedlessOnboardingControllerStateWithDefaults as getDefaultSeedlessOnboardingControllerState, } from './SeedlessOnboardingController'; export type { - AuthenticatedUserDetails, - SocialBackupsMetadata, - SeedlessOnboardingControllerState, SeedlessOnboardingControllerOptions, SeedlessOnboardingControllerMessenger, SeedlessOnboardingControllerGetStateAction, + SeedlessOnboardingControllerGetAccessTokenAction, SeedlessOnboardingControllerStateChangeEvent, SeedlessOnboardingControllerActions, SeedlessOnboardingControllerEvents, +} from './SeedlessOnboardingController'; +export type { + AuthenticatedUserDetails, + SocialBackupsMetadata, + SeedlessOnboardingControllerState, ToprfKeyDeriver, RecoveryErrorData, } from './types'; @@ -22,4 +25,4 @@ export { SecretType, } from './constants'; export { SecretMetadata } from './SecretMetadata'; -export { RecoveryError } from './errors'; +export { RecoveryError, SeedlessOnboardingError } from './errors'; diff --git a/packages/seedless-onboarding-controller/src/types.ts b/packages/seedless-onboarding-controller/src/types.ts index a9e17731dc5..b1713f04882 100644 --- a/packages/seedless-onboarding-controller/src/types.ts +++ b/packages/seedless-onboarding-controller/src/types.ts @@ -1,18 +1,11 @@ -import type { - ControllerGetStateAction, - ControllerStateChangeEvent, -} from '@metamask/base-controller'; import type { Encryptor } from '@metamask/keyring-controller'; -import type { Messenger } from '@metamask/messenger'; import type { KeyPair, NodeAuthTokens } from '@metamask/toprf-secure-backup'; import type { MutexInterface } from 'async-mutex'; import type { AuthConnection, - controllerName, SecretMetadataVersion, SecretType, - Web3AuthNetwork, } from './constants'; /** @@ -184,42 +177,11 @@ export type SeedlessOnboardingControllerState = isSeedlessOnboardingUserAuthenticated: boolean; }; -// Actions -export type SeedlessOnboardingControllerGetStateAction = - ControllerGetStateAction< - typeof controllerName, - SeedlessOnboardingControllerState - >; -export type SeedlessOnboardingControllerActions = - SeedlessOnboardingControllerGetStateAction; - -type AllowedActions = never; - -// Events -export type SeedlessOnboardingControllerStateChangeEvent = - ControllerStateChangeEvent< - typeof controllerName, - SeedlessOnboardingControllerState - >; -export type SeedlessOnboardingControllerEvents = - SeedlessOnboardingControllerStateChangeEvent; - -type AllowedEvents = never; - -// Messenger -export type SeedlessOnboardingControllerMessenger = Messenger< - typeof controllerName, - SeedlessOnboardingControllerActions | AllowedActions, - SeedlessOnboardingControllerEvents | AllowedEvents ->; - /** * Encryptor interface for encrypting and decrypting seedless onboarding vault. */ -export type VaultEncryptor = Omit< - Encryptor, - 'encryptWithKey' ->; +export type VaultEncryptor = + Encryptor; /** * Additional key deriver for the TOPRF client. @@ -263,73 +225,6 @@ export type RenewRefreshToken = (params: { newRefreshToken: string; }>; -/** - * Seedless Onboarding Controller Options. - * - * @param messenger - The messenger to use for this controller. - * @param state - The initial state to set on this controller. - * @param encryptor - The encryptor to use for encrypting and decrypting seedless onboarding vault. - */ -export type SeedlessOnboardingControllerOptions< - EncryptionKey, - SupportedKeyDerivationParams, -> = { - messenger: SeedlessOnboardingControllerMessenger; - - /** - * Initial state to set on this controller. - */ - state?: Partial; - - /** - * Encryptor to use for encrypting and decrypting seedless onboarding vault. - * - * @default browser-passworder @link https://github.com/MetaMask/browser-passworder - */ - encryptor: VaultEncryptor; - - /** - * A function to get a new jwt token using refresh token. - */ - refreshJWTToken: RefreshJWTToken; - - /** - * A function to revoke the refresh token. - */ - revokeRefreshToken: RevokeRefreshToken; - - /** - * A function to renew the refresh token and get new revoke token. - */ - renewRefreshToken: RenewRefreshToken; - - /** - * Optional key derivation interface for the TOPRF client. - * - * If provided, it will be used as an additional step during - * key derivation. This can be used, for example, to inject a slow key - * derivation step to protect against local brute force attacks on the - * password. - * - * @default browser-passworder @link https://github.com/MetaMask/browser-passworder - */ - toprfKeyDeriver?: ToprfKeyDeriver; - - /** - * Type of Web3Auth network to be used for the Seedless Onboarding flow. - * - * @default Web3AuthNetwork.Mainnet - */ - network?: Web3AuthNetwork; - - /** - * The TTL of the password outdated cache in milliseconds. - * - * @default PASSWORD_OUTDATED_CACHE_TTL_MS - */ - passwordOutdatedCacheTTL?: number; -}; - /** * A function executed within a mutually exclusive lock, with * a mutex releaser in its option bag. diff --git a/packages/seedless-onboarding-controller/src/utils.test.ts b/packages/seedless-onboarding-controller/src/utils.test.ts index 497c8e8c896..5f3c4fda48c 100644 --- a/packages/seedless-onboarding-controller/src/utils.test.ts +++ b/packages/seedless-onboarding-controller/src/utils.test.ts @@ -1,8 +1,13 @@ import { bytesToBase64 } from '@metamask/utils'; import { utf8ToBytes } from '@noble/ciphers/utils'; -import type { DecodedNodeAuthToken, DecodedBaseJWTToken } from './types'; -import { decodeNodeAuthToken, decodeJWTToken } from './utils'; +import type { DecodedNodeAuthToken } from './types'; +import { + decodeNodeAuthToken, + decodeJWTToken, + compareAndGetLatestToken, +} from './utils'; +import { createMockJWTToken } from '../tests/mocks/utils'; describe('utils', () => { describe('decodeNodeAuthToken', () => { @@ -75,34 +80,6 @@ describe('utils', () => { }); describe('decodeJWTToken', () => { - /** - * Creates a mock JWT token for testing - * - * @param payload - The payload to encode - * @returns The JWT token string - */ - const createMockJWTToken = ( - payload: Partial = {}, - ): string => { - const defaultPayload: DecodedBaseJWTToken = { - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now - iat: Math.floor(Date.now() / 1000), // issued now - aud: 'mock_audience', - iss: 'mock_issuer', - sub: 'mock_subject', - ...payload, - }; - const header = { alg: 'HS256', typ: 'JWT' }; - const encodedHeader = Buffer.from(JSON.stringify(header)).toString( - 'base64', - ); - const encodedPayload = Buffer.from( - JSON.stringify(defaultPayload), - ).toString('base64'); - const signature = 'mock_signature'; - return `${encodedHeader}.${encodedPayload}.${signature}`; - }; - it('should successfully decode a valid JWT token', () => { const mockPayload = { exp: 1234567890, @@ -175,4 +152,70 @@ describe('utils', () => { expect(result.sub).toBe('user-123@example.com'); }); }); + + describe('compareAndGetLatestToken', () => { + it('should return the first token when it has a later expiration', () => { + const laterToken = createMockJWTToken({ exp: 2000000000 }); // Later expiration + const earlierToken = createMockJWTToken({ exp: 1000000000 }); // Earlier expiration + + const result = compareAndGetLatestToken(laterToken, earlierToken); + + expect(result).toBe(laterToken); + }); + + it('should return the second token when it has a later expiration', () => { + const earlierToken = createMockJWTToken({ exp: 1000000000 }); // Earlier expiration + const laterToken = createMockJWTToken({ exp: 2000000000 }); // Later expiration + + const result = compareAndGetLatestToken(earlierToken, laterToken); + + expect(result).toBe(laterToken); + }); + + it('should return the second token when both have the same expiration', () => { + const token1 = createMockJWTToken({ exp: 1500000000 }); + const token2 = createMockJWTToken({ exp: 1500000000 }); + + const result = compareAndGetLatestToken(token1, token2); + + expect(result).toBe(token2); + }); + + it('should return the second token when the first token is invalid', () => { + const invalidToken = 'invalid.token'; // Missing signature part + const validToken = createMockJWTToken({ exp: 1500000000 }); + + const result = compareAndGetLatestToken(invalidToken, validToken); + + expect(result).toBe(validToken); + }); + + it('should return the first token when the second token is invalid', () => { + const validToken = createMockJWTToken({ exp: 1500000000 }); + const invalidToken = 'not-a-valid-jwt-token'; + + const result = compareAndGetLatestToken(validToken, invalidToken); + + expect(result).toBe(validToken); + }); + + it('should return the second token when both tokens are invalid', () => { + const invalidToken1 = 'invalid.token'; + const invalidToken2 = 'also.invalid'; + + const result = compareAndGetLatestToken(invalidToken1, invalidToken2); + + // First token is invalid, so it returns the second token + expect(result).toBe(invalidToken2); + }); + + it('should handle tokens with expiration times close together', () => { + const token1 = createMockJWTToken({ exp: 1500000001 }); + const token2 = createMockJWTToken({ exp: 1500000000 }); + + const result = compareAndGetLatestToken(token1, token2); + + expect(result).toBe(token1); + }); + }); }); diff --git a/packages/seedless-onboarding-controller/src/utils.ts b/packages/seedless-onboarding-controller/src/utils.ts index b769c9f9f76..84022a4c80b 100644 --- a/packages/seedless-onboarding-controller/src/utils.ts +++ b/packages/seedless-onboarding-controller/src/utils.ts @@ -112,3 +112,37 @@ export function deserializeAuthKeyPair(value: string): KeyPair { pk: base64ToBytes(parsedKeyPair.pk), }; } + +/** + * Compare two JWT tokens and return the latest token. + * + * @param jwtToken1 - The first JWT token to compare. + * @param jwtToken2 - The second JWT token to compare. + * @returns The latest JWT token. + */ +export function compareAndGetLatestToken( + jwtToken1: string, + jwtToken2: string, +): string { + let decodedToken1: DecodedBaseJWTToken; + let decodedToken2: DecodedBaseJWTToken; + + try { + decodedToken1 = decodeJWTToken(jwtToken1); + } catch { + // if the first token is invalid, return the second token + return jwtToken2; + } + + try { + decodedToken2 = decodeJWTToken(jwtToken2); + } catch { + // if the second token is invalid, return the first token + return jwtToken1; + } + + if (decodedToken1.exp > decodedToken2.exp) { + return jwtToken1; + } + return jwtToken2; +} diff --git a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts index d821f12c4da..51a791e5d17 100644 --- a/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts +++ b/packages/seedless-onboarding-controller/tests/__fixtures__/mockMessenger.ts @@ -10,7 +10,7 @@ import type { } from '@metamask/messenger'; import { controllerName } from '../../src/constants'; -import type { SeedlessOnboardingControllerMessenger } from '../../src/types'; +import type { SeedlessOnboardingControllerMessenger } from '../../src/SeedlessOnboardingController'; export type AllSeedlessOnboardingControllerActions = MessengerActions; diff --git a/packages/seedless-onboarding-controller/tests/mocks/utils.ts b/packages/seedless-onboarding-controller/tests/mocks/utils.ts new file mode 100644 index 00000000000..b49f719f42c --- /dev/null +++ b/packages/seedless-onboarding-controller/tests/mocks/utils.ts @@ -0,0 +1,27 @@ +import type { DecodedBaseJWTToken } from '../../src/types'; + +/** + * Creates a mock JWT token for testing + * + * @param payload - The payload to encode + * @returns The JWT token string + */ +export function createMockJWTToken( + payload: Partial = {}, +): string { + const defaultPayload: DecodedBaseJWTToken = { + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now + iat: Math.floor(Date.now() / 1000), // issued now + aud: 'mock_audience', + iss: 'mock_issuer', + sub: 'mock_subject', + ...payload, + }; + const header = { alg: 'HS256', typ: 'JWT' }; + const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64'); + const encodedPayload = Buffer.from(JSON.stringify(defaultPayload)).toString( + 'base64', + ); + const signature = 'mock_signature'; + return `${encodedHeader}.${encodedPayload}.${signature}`; +} diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index ea7c1063c37..317494d4ba7 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -59,15 +59,15 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "immer": "^9.0.6", - "jest": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "lodash": "^4.17.21", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts index ee89faeae9a..275136be1d7 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkController.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkController.test.ts @@ -879,7 +879,7 @@ describe('PermissionController:stateChange', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -892,8 +892,8 @@ describe('PermissionController:stateChange', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "domains": Object {}, + { + "domains": {}, } `); }); @@ -908,8 +908,8 @@ describe('PermissionController:stateChange', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "domains": Object {}, + { + "domains": {}, } `); }); @@ -924,8 +924,8 @@ describe('PermissionController:stateChange', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "domains": Object {}, + { + "domains": {}, } `); }); diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md index f5af439fc09..0b6f0a282b8 100644 --- a/packages/shield-controller/CHANGELOG.md +++ b/packages/shield-controller/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/transaction-controller` from `^62.12.0` to `^62.16.0` ([#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/transaction-controller` from `^62.12.0` to `^62.17.0` ([#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/signature-controller` from `39.0.1` to `39.0.3` ([#7897](https://github.com/MetaMask/core/pull/7897), [#7946](https://github.com/MetaMask/core/pull/7946)) ## [5.0.1] diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json index 6d4985b5008..3b004cb511a 100644 --- a/packages/shield-controller/package.json +++ b/packages/shield-controller/package.json @@ -51,8 +51,8 @@ "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/messenger": "^0.3.0", - "@metamask/signature-controller": "^39.0.1", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/signature-controller": "^39.0.3", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "cockatiel": "^3.1.2" }, @@ -62,12 +62,12 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", + "jest": "^29.7.0", "lodash": "^4.17.21", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3", "uuid": "^8.3.2" diff --git a/packages/shield-controller/src/ShieldController.test.ts b/packages/shield-controller/src/ShieldController.test.ts index fb1f50a7951..fd13b0e81ee 100644 --- a/packages/shield-controller/src/ShieldController.test.ts +++ b/packages/shield-controller/src/ShieldController.test.ts @@ -540,7 +540,7 @@ describe('ShieldController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', async () => { @@ -553,11 +553,11 @@ describe('ShieldController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "coverageResults": Object {}, - "orderedTransactionHistory": Array [], + { + "coverageResults": {}, + "orderedTransactionHistory": [], } - `); + `); }); it('persists expected state', async () => { @@ -570,11 +570,11 @@ describe('ShieldController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "coverageResults": Object {}, - "orderedTransactionHistory": Array [], + { + "coverageResults": {}, + "orderedTransactionHistory": [], } - `); + `); }); it('exposes expected state to UI', async () => { @@ -587,10 +587,10 @@ describe('ShieldController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "coverageResults": Object {}, - } - `); + { + "coverageResults": {}, + } + `); }); }); diff --git a/packages/signature-controller/CHANGELOG.md b/packages/signature-controller/CHANGELOG.md index 4aaf3bde877..4bb4894022f 100644 --- a/packages/signature-controller/CHANGELOG.md +++ b/packages/signature-controller/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [39.0.3] + +### Changed + +- Bump `@metamask/gator-permissions-controller` from `^1.1.2` to `^2.0.0` ([#7946](https://github.com/MetaMask/core/pull/7946)) + +## [39.0.2] + ### Changed +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713)) - Bump `@metamask/gator-permissions-controller` from `^1.0.0` to `^1.1.2` ([#7682](https://github.com/MetaMask/core/pull/7682), [#7739](https://github.com/MetaMask/core/pull/7739), [#7767](https://github.com/MetaMask/core/pull/7767)) @@ -666,7 +675,9 @@ 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@39.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@39.0.3...HEAD +[39.0.3]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@39.0.2...@metamask/signature-controller@39.0.3 +[39.0.2]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@39.0.1...@metamask/signature-controller@39.0.2 [39.0.1]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@39.0.0...@metamask/signature-controller@39.0.1 [39.0.0]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@38.0.1...@metamask/signature-controller@39.0.0 [38.0.1]: https://github.com/MetaMask/core/compare/@metamask/signature-controller@38.0.0...@metamask/signature-controller@38.0.1 diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index 42a82c0c836..48c0b8554e6 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/signature-controller", - "version": "39.0.1", + "version": "39.0.3", "description": "Processes signing requests in order to sign arbitrary and typed data", "keywords": [ "MetaMask", @@ -48,12 +48,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/eth-sig-util": "^8.2.0", - "@metamask/gator-permissions-controller": "^1.1.2", + "@metamask/gator-permissions-controller": "^2.0.0", "@metamask/keyring-controller": "^25.1.0", "@metamask/logging-controller": "^7.0.1", "@metamask/messenger": "^0.3.0", @@ -66,11 +66,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/signature-controller/src/SignatureController.test.ts b/packages/signature-controller/src/SignatureController.test.ts index 379e72a2edb..e90100117f1 100644 --- a/packages/signature-controller/src/SignatureController.test.ts +++ b/packages/signature-controller/src/SignatureController.test.ts @@ -1536,7 +1536,7 @@ describe('SignatureController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -1549,11 +1549,11 @@ describe('SignatureController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "signatureRequests": Object {}, + { + "signatureRequests": {}, "unapprovedPersonalMsgCount": 0, - "unapprovedPersonalMsgs": Object {}, - "unapprovedTypedMessages": Object {}, + "unapprovedPersonalMsgs": {}, + "unapprovedTypedMessages": {}, "unapprovedTypedMessagesCount": 0, } `); @@ -1568,7 +1568,7 @@ describe('SignatureController', () => { controller.metadata, 'persist', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('exposes expected state to UI', () => { @@ -1581,11 +1581,11 @@ describe('SignatureController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "signatureRequests": Object {}, + { + "signatureRequests": {}, "unapprovedPersonalMsgCount": 0, - "unapprovedPersonalMsgs": Object {}, - "unapprovedTypedMessages": Object {}, + "unapprovedPersonalMsgs": {}, + "unapprovedTypedMessages": {}, "unapprovedTypedMessagesCount": 0, } `); diff --git a/packages/storage-service/package.json b/packages/storage-service/package.json index c18881aa35d..16ed3d774ee 100644 --- a/packages/storage-service/package.json +++ b/packages/storage-service/package.json @@ -54,11 +54,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 12a8576b9fa..4290f02c21a 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Bump `@metamask/transaction-controller` from `62.16.0` to `62.17.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) + ## [6.0.0] ### Added diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 9e5db034133..80e20628aa4 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -53,19 +53,18 @@ "@metamask/messenger": "^0.3.0", "@metamask/polling-controller": "^16.0.2", "@metamask/profile-sync-controller": "^27.1.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 628d2b2735d..8d527264619 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -10,7 +10,6 @@ import { TransactionType, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import * as sinon from 'sinon'; import { controllerName, @@ -52,7 +51,7 @@ import { SUBSCRIPTION_STATUSES, SubscriptionUserEvent, } from './types'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; import { generateMockTxMeta } from '../tests/utils'; type AllActions = MessengerActions; @@ -1047,21 +1046,19 @@ describe('SubscriptionController', () => { }); describe('startPolling', () => { - let clock: sinon.SinonFakeTimers; beforeEach(() => { - // eslint-disable-next-line import-x/namespace - clock = sinon.useFakeTimers(); + jest.useFakeTimers(); }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); it('should call getSubscriptions with the correct interval', async () => { await withController(async ({ controller }) => { const getSubscriptionsSpy = jest.spyOn(controller, 'getSubscriptions'); controller.startPolling({}); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(getSubscriptionsSpy).toHaveBeenCalledTimes(1); }); }); @@ -1076,7 +1073,7 @@ describe('SubscriptionController', () => { 'triggerAccessTokenRefresh', ); controller.startPolling({}); - await advanceTime({ clock, duration: 0 }); + await jestAdvanceTime({ duration: 0 }); expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); }); }); @@ -1422,8 +1419,8 @@ describe('SubscriptionController', () => { 'includeInDebugSnapshot', ), ).toMatchInlineSnapshot(` - Object { - "trialedProducts": Array [], + { + "trialedProducts": [], } `); }); @@ -1438,10 +1435,10 @@ describe('SubscriptionController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "trialedProducts": Array [], - } - `); + { + "trialedProducts": [], + } + `); }); }); @@ -1454,11 +1451,11 @@ describe('SubscriptionController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "subscriptions": Array [], - "trialedProducts": Array [], - } - `); + { + "subscriptions": [], + "trialedProducts": [], + } + `); }); }); @@ -1471,11 +1468,11 @@ describe('SubscriptionController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "subscriptions": Array [], - "trialedProducts": Array [], - } - `); + { + "subscriptions": [], + "trialedProducts": [], + } + `); }); }); }); diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index fccd687d794..2f2d631da26 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [62.17.0] + +### Added + +- Add optional `isPostQuote` to `MetamaskPayMetadata` for post-quote withdrawal flows ([#7783](https://github.com/MetaMask/core/pull/7783)) + +### Changed + +- Bump `@metamask/accounts-controller` from `^35.0.2` to `^36.0.0` ([#7897](https://github.com/MetaMask/core/pull/7897)) + ## [62.16.0] ### Added @@ -2154,7 +2164,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/transaction-controller@62.16.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.17.0...HEAD +[62.17.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.16.0...@metamask/transaction-controller@62.17.0 [62.16.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.15.0...@metamask/transaction-controller@62.16.0 [62.15.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.14.0...@metamask/transaction-controller@62.15.0 [62.14.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-controller@62.13.0...@metamask/transaction-controller@62.14.0 diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index b8c1c78ee4b..999a84a4c39 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-controller", - "version": "62.16.0", + "version": "62.17.0", "description": "Stores transactions alongside their periodically updated statuses and manages interactions such as approval and cancellation", "keywords": [ "MetaMask", @@ -55,7 +55,7 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@ethersproject/wallet": "^5.7.0", - "@metamask/accounts-controller": "^35.0.2", + "@metamask/accounts-controller": "^36.0.0", "@metamask/approval-controller": "^8.0.0", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.18.0", @@ -85,15 +85,15 @@ "@metamask/ethjs-provider-http": "^0.3.0", "@ts-bridge/cli": "^0.6.4", "@types/bn.js": "^5.1.5", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "@types/node": "^16.18.54", "deepmerge": "^4.2.2", "immer": "^9.0.6", - "jest": "^27.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "nock": "^13.3.1", - "sinon": "^9.2.4", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 10e543c6487..517a3282981 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -8174,7 +8174,7 @@ describe('TransactionController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -8187,12 +8187,12 @@ describe('TransactionController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "lastFetchedBlockNumbers": Object {}, - "methodData": Object {}, - "submitHistory": Array [], - "transactionBatches": Array [], - "transactions": Array [], + { + "lastFetchedBlockNumbers": {}, + "methodData": {}, + "submitHistory": [], + "transactionBatches": [], + "transactions": [], } `); }); @@ -8207,12 +8207,12 @@ describe('TransactionController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "lastFetchedBlockNumbers": Object {}, - "methodData": Object {}, - "submitHistory": Array [], - "transactionBatches": Array [], - "transactions": Array [], + { + "lastFetchedBlockNumbers": {}, + "methodData": {}, + "submitHistory": [], + "transactionBatches": [], + "transactions": [], } `); }); @@ -8227,10 +8227,10 @@ describe('TransactionController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "methodData": Object {}, - "transactionBatches": Array [], - "transactions": Array [], + { + "methodData": {}, + "transactionBatches": [], + "transactions": [], } `); }); diff --git a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts index a66d5c6dc7f..4fa61e6e7c2 100644 --- a/packages/transaction-controller/src/TransactionControllerIntegration.test.ts +++ b/packages/transaction-controller/src/TransactionControllerIntegration.test.ts @@ -33,8 +33,6 @@ import type { } from '@metamask/network-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import assert from 'assert'; -import type { SinonFakeTimers } from 'sinon'; -import { useFakeTimers } from 'sinon'; import { v4 as uuidV4 } from 'uuid'; import type { @@ -44,7 +42,7 @@ import type { import { TransactionController } from './TransactionController'; import type { InternalAccount } from './types'; import { TransactionStatus, TransactionType } from './types'; -import { advanceTime } from '../../../tests/helpers'; +import { jestAdvanceTime } from '../../../tests/helpers'; import { mockNetwork } from '../../../tests/mock-network'; import { buildAddNetworkFields, @@ -336,11 +334,10 @@ const setupController = async ( }; describe('TransactionController Integration', () => { - let clock: SinonFakeTimers; let uuidCounter = 0; beforeEach(() => { - clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); uuidV4Mock.mockImplementation(() => { const uuid = `UUID-${uuidCounter}`; @@ -350,7 +347,7 @@ describe('TransactionController Integration', () => { }); afterEach(() => { - clock.restore(); + jest.useRealTimers(); }); describe('constructor', () => { @@ -462,8 +459,8 @@ describe('TransactionController Integration', () => { }, }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(transactionController.state.transactions).toMatchObject([ expect.objectContaining({ @@ -539,7 +536,7 @@ describe('TransactionController Integration', () => { ); await approvalController.accept(transactionMeta.id); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await result; @@ -584,13 +581,13 @@ describe('TransactionController Integration', () => { ); await approvalController.accept(transactionMeta.id); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await result; // blocktracker polling is 20s - await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(transactionController.state.transactions).toHaveLength(1); expect(transactionController.state.transactions[0].status).toBe( @@ -663,15 +660,15 @@ describe('TransactionController Integration', () => { approvalController.accept(firstTransaction.transactionMeta.id), approvalController.accept(secondTransaction.transactionMeta.id), ]); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await Promise.all([firstTransaction.result, secondTransaction.result]); // blocktracker polling is 20s - await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(transactionController.state.transactions).toHaveLength(2); expect(transactionController.state.transactions[0].status).toBe( @@ -729,7 +726,7 @@ describe('TransactionController Integration', () => { ); await approvalController.accept(transactionMeta.id); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await result; @@ -794,19 +791,19 @@ describe('TransactionController Integration', () => { ); await approvalController.accept(transactionMeta.id); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await result; await transactionController.stopTransaction(transactionMeta.id); // blocktracker polling is 20s - await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(transactionController.state.transactions).toHaveLength(2); expect(transactionController.state.transactions[0].status).toBe( @@ -871,19 +868,19 @@ describe('TransactionController Integration', () => { ); await approvalController.accept(transactionMeta.id); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await result; await transactionController.speedUpTransaction(transactionMeta.id); // blocktracker polling is 20s - await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: BLOCK_TRACKER_POLLING_INTERVAL }); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: BLOCK_TRACKER_POLLING_INTERVAL }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); expect(transactionController.state.transactions).toHaveLength(2); expect(transactionController.state.transactions[0].status).toBe( @@ -1016,8 +1013,8 @@ describe('TransactionController Integration', () => { approvalController.accept(addTx1.transactionMeta.id), approvalController.accept(addTx2.transactionMeta.id), ]); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await Promise.all([addTx1.result, addTx2.result]); @@ -1075,7 +1072,7 @@ describe('TransactionController Integration', () => { { networkClientId: 'sepolia' }, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const addTx2 = await transactionController.addTransaction( { @@ -1087,15 +1084,15 @@ describe('TransactionController Integration', () => { }, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await Promise.all([ approvalController.accept(addTx1.transactionMeta.id), approvalController.accept(addTx2.transactionMeta.id), ]); - await advanceTime({ clock, duration: 1 }); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); + await jestAdvanceTime({ duration: 1 }); await Promise.all([addTx1.result, addTx2.result]); @@ -1271,7 +1268,7 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, networkClientId, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const nonceLock = await nonceLockPromise; @@ -1306,7 +1303,7 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, networkClientId, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const firstNonceLock = await firstNonceLockPromise; @@ -1320,7 +1317,7 @@ describe('TransactionController Integration', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor new Promise(async (resolve) => { - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); resolve(null); }); @@ -1333,7 +1330,7 @@ describe('TransactionController Integration', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/await-thenable await firstNonceLock.releaseLock(); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); secondNonceLockIfAcquired = await Promise.race([ secondNonceLockPromise, @@ -1399,7 +1396,7 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, 'sepolia', ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const firstNonceLock = await firstNonceLockPromise; @@ -1413,7 +1410,7 @@ describe('TransactionController Integration', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor new Promise(async (resolve) => { - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); resolve(null); }); @@ -1426,7 +1423,7 @@ describe('TransactionController Integration', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/await-thenable await firstNonceLock.releaseLock(); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); secondNonceLockIfAcquired = await Promise.race([ secondNonceLockPromise, @@ -1464,7 +1461,7 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, 'linea-sepolia', ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const firstNonceLock = await firstNonceLockPromise; @@ -1474,7 +1471,7 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, 'sepolia', ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const secondNonceLock = await secondNonceLockPromise; @@ -1513,7 +1510,7 @@ describe('TransactionController Integration', () => { ACCOUNT_MOCK, networkClientId, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const firstNonceLock = await firstNonceLockPromise; @@ -1523,7 +1520,7 @@ describe('TransactionController Integration', () => { ACCOUNT_2_MOCK, networkClientId, ); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); const secondNonceLock = await secondNonceLockPromise; diff --git a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts index 03884dc65cd..af0cc2c3553 100644 --- a/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts +++ b/packages/transaction-controller/src/helpers/MultichainTrackingHelper.test.ts @@ -6,14 +6,13 @@ import type { } from '@metamask/network-controller'; import type { NonceTracker } from '@metamask/nonce-tracker'; import type { Hex } from '@metamask/utils'; -import { useFakeTimers } from 'sinon'; import { MultichainTrackingHelper, MultichainTrackingHelperOptions, } from './MultichainTrackingHelper'; import type { PendingTransactionTracker } from './PendingTransactionTracker'; -import { advanceTime } from '../../../../tests/helpers'; +import { jestAdvanceTime } from '../../../../tests/helpers'; jest.mock( '@metamask/eth-query', @@ -473,7 +472,7 @@ describe('MultichainTrackingHelper', () => { }); it('should block on attempts to get the lock for the same chainId and key combination', async () => { - const clock = useFakeTimers(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); const { helper } = newMultichainTrackingHelper(); const firstReleaseLockPromise = helper.acquireNonceLockForChainIdKey({ @@ -492,7 +491,7 @@ describe('MultichainTrackingHelper', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor new Promise(async (resolve) => { - await advanceTime({ clock, duration: 100 }); + await jestAdvanceTime({ duration: 100 }); resolve(null); }); @@ -505,7 +504,7 @@ describe('MultichainTrackingHelper', () => { // TODO: Either fix this lint violation or explain why it's necessary to ignore. // eslint-disable-next-line @typescript-eslint/await-thenable await firstReleaseLock(); - await advanceTime({ clock, duration: 1 }); + await jestAdvanceTime({ duration: 1 }); secondReleaseLockIfAcquired = await Promise.race([ secondReleaseLockPromise, @@ -514,7 +513,7 @@ describe('MultichainTrackingHelper', () => { expect(secondReleaseLockIfAcquired).toStrictEqual(expect.any(Function)); - clock.restore(); + jest.useRealTimers(); }); }); diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 0012653529f..8fa03cabc0a 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2096,6 +2096,12 @@ export type MetamaskPayMetadata = { /** Chain ID of the payment token. */ chainId?: Hex; + /** + * Whether this is a post-quote transaction (e.g., withdrawal flow). + * When true, the token represents the destination rather than source. + */ + isPostQuote?: boolean; + /** Total network fee in fiat currency, including the original and bridge transactions. */ networkFeeFiat?: string; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index bf5c196fd26..dbc9f4da646 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,11 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [15.1.0] + +### Changed + +- Bump `@metamask/bridge-controller` from `^66.1.1` to `^67.0.0` ([#7956](https://github.com/MetaMask/core/pull/7956), [#7961](https://github.com/MetaMask/core/pull/7961)) +- Bump `@metamask/bridge-status-controller` from `^66.0.2` to `^67.0.0` ([#7956](https://github.com/MetaMask/core/pull/7956), [#7961](https://github.com/MetaMask/core/pull/7961)) +- Bump `@metamask/assets-controllers` from `^99.3.2` to `^99.4.0` ([#7944](https://github.com/MetaMask/core/pull/7944)) + +## [15.0.1] + +### Fixed + +- Estimate relay transactions separately and combine with original transaction gas at quote time ([#7933](https://github.com/MetaMask/core/pull/7933)) + +## [15.0.0] + +### Changed + +- **BREAKING:** Remove `transactionGas` from `TransactionPayFees` ([#7929](https://github.com/MetaMask/core/pull/7929)) + - The original transaction's gas cost is now included in `sourceNetwork` for post-quote flows instead of being reported separately, so gas estimation and gas-fee-token detection cover both the Relay deposit and the user's original transaction + +## [14.0.0] + ### Changed -- Bump `@metamask/bridge-controller` from `^65.3.0` to `^66.0.0` ([#7862](https://github.com/MetaMask/core/pull/7862)) -- Bump `@metamask/transaction-controller` from `^62.14.0` to `^62.16.0` ([#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) -- Bump `@metamask/assets-controllers` from `^99.2.0` to `^99.3.1` ([#7855](https://github.com/MetaMask/core/pull/7855), [#7860](https://github.com/MetaMask/core/pull/7860)) +- **BREAKING:** Add subsidized fee to Relay quote target amount if `isMaxAmount` ([#7911](https://github.com/MetaMask/core/pull/7911)) + - Remove `human` and `raw` from `targetAmount` on `TransactionPayQuote` and `TransactionPayTotals` + - Use `amountFormatted` as USD value for Relay quote target amount and subsidized fee when token is a stablecoin + - Set provider fee to zero when subsidized fee is present + - Add MUSD, USDC, USDT, and Hypercore USDC to stablecoins + +## [13.0.0] + +### Added + +- Add post-quote transaction support for withdrawal flows ([#7783](https://github.com/MetaMask/core/pull/7783)) + - Add `setTransactionConfig` method replacing `setIsMaxAmount` and `setIsPostQuote` + - Add `TransactionConfig`, `TransactionConfigCallback` types + - Add `isPostQuote` to `TransactionData` and `QuoteRequest` + - Support reversed source/destination in Relay quotes for post-quote flows + - Add same-token-same-chain skip logic for post-quote transactions + - Add source amount fields (`sourceBalanceRaw`, `sourceChainId`, `sourceTokenAddress`) to `TransactionPaySourceAmount` + +### Changed + +- Bump `@metamask/bridge-controller` from `^65.3.0` to `^66.1.1` ([#7862](https://github.com/MetaMask/core/pull/7862), [#7897](https://github.com/MetaMask/core/pull/7897), [#7910](https://github.com/MetaMask/core/pull/7910)) +- Bump `@metamask/transaction-controller` from `^62.14.0` to `^62.17.0` ([#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872), [#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/assets-controllers` from `^99.2.0` to `^99.3.2` ([#7855](https://github.com/MetaMask/core/pull/7855), [#7860](https://github.com/MetaMask/core/pull/7860)), ([#7897](https://github.com/MetaMask/core/pull/7897)) +- Bump `@metamask/bridge-status-controller` from `66.0.0` to `66.0.2` ([#7897](https://github.com/MetaMask/core/pull/7897), [#7910](https://github.com/MetaMask/core/pull/7910)) + +### Removed + +- **BREAKING:** Remove `setIsMaxAmount` method in favor of `setTransactionConfig` ([#7783](https://github.com/MetaMask/core/pull/7783)) ## [12.2.0] @@ -346,7 +394,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@12.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@15.1.0...HEAD +[15.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@15.0.1...@metamask/transaction-pay-controller@15.1.0 +[15.0.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@15.0.0...@metamask/transaction-pay-controller@15.0.1 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@14.0.0...@metamask/transaction-pay-controller@15.0.0 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@13.0.0...@metamask/transaction-pay-controller@14.0.0 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@12.2.0...@metamask/transaction-pay-controller@13.0.0 [12.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@12.1.0...@metamask/transaction-pay-controller@12.2.0 [12.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@12.0.2...@metamask/transaction-pay-controller@12.1.0 [12.0.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@12.0.1...@metamask/transaction-pay-controller@12.0.2 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 250267b5889..9d96473f595 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "12.2.0", + "version": "15.1.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "MetaMask", @@ -51,17 +51,17 @@ "dependencies": { "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", - "@metamask/assets-controllers": "^99.3.1", + "@metamask/assets-controllers": "^99.4.0", "@metamask/base-controller": "^9.0.0", - "@metamask/bridge-controller": "^66.0.0", - "@metamask/bridge-status-controller": "^66.0.0", + "@metamask/bridge-controller": "^67.0.0", + "@metamask/bridge-status-controller": "^67.0.0", "@metamask/controller-utils": "^11.18.0", "@metamask/gas-fee-controller": "^26.0.2", "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/network-controller": "^29.0.0", "@metamask/remote-feature-flag-controller": "^4.0.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "bignumber.js": "^9.1.2", "bn.js": "^5.2.1", @@ -71,11 +71,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 82232be1a13..37cb66393a5 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -74,16 +74,64 @@ describe('TransactionPayController', () => { }); }); - describe('setIsMaxAmount', () => { - it('updates state', () => { + describe('setTransactionConfig', () => { + it('updates isMaxAmount in state', () => { const controller = createController(); - controller.setIsMaxAmount(TRANSACTION_ID_MOCK, true); + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.isMaxAmount = true; + }); expect( controller.state.transactionData[TRANSACTION_ID_MOCK].isMaxAmount, ).toBe(true); }); + + it('updates isPostQuote in state', () => { + const controller = createController(); + + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.isPostQuote = true; + }); + + expect( + controller.state.transactionData[TRANSACTION_ID_MOCK].isPostQuote, + ).toBe(true); + }); + + it('triggers source amounts and quotes update when only isPostQuote changes', () => { + const controller = createController(); + + // First call creates the entry with defaults + controller.setTransactionConfig(TRANSACTION_ID_MOCK, () => { + // no-op, just initializes + }); + + updateSourceAmountsMock.mockClear(); + updateQuotesMock.mockClear(); + + // Second call only changes isPostQuote + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.isPostQuote = true; + }); + + expect(updateSourceAmountsMock).toHaveBeenCalledTimes(1); + expect(updateQuotesMock).toHaveBeenCalledTimes(1); + }); + + it('updates multiple config properties at once', () => { + const controller = createController(); + + controller.setTransactionConfig(TRANSACTION_ID_MOCK, (config) => { + config.isMaxAmount = true; + config.isPostQuote = true; + }); + + const transactionData = + controller.state.transactionData[TRANSACTION_ID_MOCK]; + expect(transactionData.isMaxAmount).toBe(true); + expect(transactionData.isPostQuote).toBe(true); + }); }); describe('getStrategy Action', () => { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 9bba57d98d8..6843235b1af 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -9,6 +9,7 @@ import { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; import { QuoteRefresher } from './helpers/QuoteRefresher'; import type { GetDelegationTransactionCallback, + TransactionConfigCallback, TransactionData, TransactionPayControllerMessenger, TransactionPayControllerOptions, @@ -74,9 +75,20 @@ export class TransactionPayController extends BaseController< }); } - setIsMaxAmount(transactionId: string, isMaxAmount: boolean): void { + setTransactionConfig( + transactionId: string, + callback: TransactionConfigCallback, + ): void { this.#updateTransactionData(transactionId, (transactionData) => { - transactionData.isMaxAmount = isMaxAmount; + const config = { + isMaxAmount: transactionData.isMaxAmount, + isPostQuote: transactionData.isPostQuote, + }; + + callback(config); + + transactionData.isMaxAmount = config.isMaxAmount; + transactionData.isPostQuote = config.isPostQuote; }); } @@ -105,6 +117,7 @@ export class TransactionPayController extends BaseController< const originalPaymentToken = current?.paymentToken; const originalTokens = current?.tokens; const originalIsMaxAmount = current?.isMaxAmount; + const originalIsPostQuote = current?.isPostQuote; if (!current) { transactionData[transactionId] = { @@ -122,8 +135,14 @@ export class TransactionPayController extends BaseController< const isTokensUpdated = current.tokens !== originalTokens; const isIsMaxUpdated = current.isMaxAmount !== originalIsMaxAmount; - - if (isPaymentTokenUpdated || isIsMaxUpdated || isTokensUpdated) { + const isPostQuoteUpdated = current.isPostQuote !== originalIsPostQuote; + + if ( + isPaymentTokenUpdated || + isIsMaxUpdated || + isTokensUpdated || + isPostQuoteUpdated + ) { updateSourceAmounts(transactionId, current as never, this.messenger); shouldUpdateQuotes = true; @@ -153,8 +172,8 @@ export class TransactionPayController extends BaseController< ); this.messenger.registerActionHandler( - 'TransactionPayController:setIsMaxAmount', - this.setIsMaxAmount.bind(this), + 'TransactionPayController:setTransactionConfig', + this.setTransactionConfig.bind(this), ); this.messenger.registerActionHandler( diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts index 8b0d24dfc9e..68bb614d678 100644 --- a/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts @@ -2,11 +2,11 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { noop } from 'lodash'; import { updatePaymentToken } from './update-payment-token'; -import type { TransactionData } from '../types'; +import type { TransactionData, TransactionPaymentToken } from '../types'; import { getTokenBalance, - getTokenInfo, getTokenFiatRate, + getTokenInfo, } from '../utils/token'; import { getTransaction } from '../utils/transaction'; @@ -18,18 +18,37 @@ const CHAIN_ID_MOCK = '0x1'; const FROM_MOCK = '0x456'; const TRANSACTION_ID_MOCK = '123-456'; +const PAYMENT_TOKEN_MOCK: TransactionPaymentToken = { + address: TOKEN_ADDRESS_MOCK, + balanceFiat: '2.46', + balanceHuman: '1.23', + balanceRaw: '1230000', + balanceUsd: '3.69', + chainId: CHAIN_ID_MOCK, + decimals: 6, + symbol: 'TST', +}; + describe('Update Payment Token Action', () => { - const getTokenBalanceMock = jest.mocked(getTokenBalance); const getTokenInfoMock = jest.mocked(getTokenInfo); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getTokenBalanceMock = jest.mocked(getTokenBalance); const getTransactionMock = jest.mocked(getTransaction); beforeEach(() => { jest.resetAllMocks(); - getTokenInfoMock.mockReturnValue({ decimals: 6, symbol: 'TST' }); + getTokenInfoMock.mockReturnValue({ + decimals: PAYMENT_TOKEN_MOCK.decimals, + symbol: PAYMENT_TOKEN_MOCK.symbol, + }); + + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '2', + usdRate: '3', + }); + getTokenBalanceMock.mockReturnValue('1230000'); - getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' }); getTransactionMock.mockReturnValue({ id: TRANSACTION_ID_MOCK, @@ -52,6 +71,12 @@ describe('Update Payment Token Action', () => { }, ); + expect(getTokenInfoMock).toHaveBeenCalledWith( + {}, + TOKEN_ADDRESS_MOCK, + CHAIN_ID_MOCK, + ); + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); const transactionDataMock = {} as TransactionData; @@ -69,7 +94,7 @@ describe('Update Payment Token Action', () => { }); }); - it('throws if decimals not found', () => { + it('throws if token info not found', () => { getTokenInfoMock.mockReturnValue(undefined); expect(() => @@ -87,7 +112,7 @@ describe('Update Payment Token Action', () => { ).toThrow('Payment token not found'); }); - it('throws if token fiat rate not found', () => { + it('throws if fiat rate not found', () => { getTokenFiatRateMock.mockReturnValue(undefined); expect(() => diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.ts index e9c7108af01..7cb8381a550 100644 --- a/packages/transaction-pay-controller/src/actions/update-payment-token.ts +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.ts @@ -26,7 +26,7 @@ export type UpdatePaymentTokenOptions = { /** * Update the payment token for a specific transaction. * - * @param request - Request parameters. + * @param request - Request parameters. * @param options - Options bag. */ export function updatePaymentToken( diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 0b6d75ff06e..f4e8074c944 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -3,6 +3,7 @@ import type { Hex } from '@metamask/utils'; export const CONTROLLER_NAME = 'TransactionPayController'; export const CHAIN_ID_ARBITRUM = '0xa4b1' as Hex; export const CHAIN_ID_POLYGON = '0x89' as Hex; +export const CHAIN_ID_HYPERCORE = '0x539' as Hex; export const NATIVE_TOKEN_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; @@ -13,6 +14,24 @@ export const ARBITRUM_USDC_ADDRESS = export const POLYGON_USDCE_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Hex; +export const STABLECOINS: Record = { + // Mainnet + '0x1': [ + '0xaca92e438df0b2401ff60da7e4337b687a2435da', // MUSD + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC + '0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT + ], + [CHAIN_ID_ARBITRUM]: [ARBITRUM_USDC_ADDRESS.toLowerCase() as Hex], + // Linea + '0xe708': [ + '0xaca92e438df0b2401ff60da7e4337b687a2435da', // MUSD + '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC + '0xa219439258ca9da29e9cc4ce5596924745e12b93', // USDT + ], + [CHAIN_ID_POLYGON]: [POLYGON_USDCE_ADDRESS.toLowerCase() as Hex], + [CHAIN_ID_HYPERCORE]: ['0x00000000000000000000000000000000'], // USDC +}; + export enum TransactionPayStrategy { Bridge = 'bridge', Relay = 'relay', diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index d3cb83c35f3..52426a80c7a 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,4 +1,6 @@ export type { + TransactionConfig, + TransactionConfigCallback, TransactionPayControllerActions, TransactionPayControllerEvents, TransactionPayControllerGetDelegationTransactionAction, @@ -6,7 +8,7 @@ export type { TransactionPayControllerGetStrategyAction, TransactionPayControllerMessenger, TransactionPayControllerOptions, - TransactionPayControllerSetIsMaxAmountAction, + TransactionPayControllerSetTransactionConfigAction, TransactionPayControllerState, TransactionPayControllerStateChangeEvent, TransactionPayControllerUpdatePaymentTokenAction, diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts index 0eb0b433b47..e169b8cb779 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts @@ -724,8 +724,6 @@ describe('Bridge Quotes Utils', () => { expect(quotes[0].targetAmount).toStrictEqual({ fiat: '24.6', - human: '12.3', - raw: QUOTE_REQUEST_1_MOCK.targetAmountMinimum, usd: '36.9', }); }); diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts index a82e6e2bbe1..4f3ddfbd2eb 100644 --- a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts @@ -515,13 +515,15 @@ function normalizeQuote( sourceFiatRate.usdRate, ); - const targetAmount = calculateAmount( + const { fiat: targetAmountFiat, usd: targetAmountUsd } = calculateAmount( request.targetAmountMinimum, quote.quote.destAsset.decimals, targetFiatRate.fiatRate, targetFiatRate.usdRate, ); + const targetAmount = { fiat: targetAmountFiat, usd: targetAmountUsd }; + const targetNetwork = calculateTransactionGasCost(transaction, messenger); const sourceNetwork = { diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index 6db45a2c20f..90d21012e44 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -1,4 +1,3 @@ -export const CHAIN_ID_HYPERCORE = '0x539'; export const RELAY_URL_BASE = 'https://api.relay.link'; export const RELAY_STATUS_URL = `${RELAY_URL_BASE}/intents/status/v3`; export const RELAY_POLLING_INTERVAL = 1000; // 1 Second diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index cd1d6667354..39ef78db397 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -6,13 +6,13 @@ import type { import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; -import { CHAIN_ID_HYPERCORE } from './constants'; import { getRelayQuotes } from './relay-quotes'; import type { RelayQuote } from './types'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM, + CHAIN_ID_HYPERCORE, CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS, } from '../../constants'; @@ -28,11 +28,7 @@ import { getGasBuffer, getSlippage, } from '../../utils/feature-flags'; -import { - calculateGasCost, - calculateGasFeeTokenCost, - calculateTransactionGasCost, -} from '../../utils/gas'; +import { calculateGasCost, calculateGasFeeTokenCost } from '../../utils/gas'; import { getNativeToken, getTokenBalance, @@ -161,10 +157,6 @@ describe('Relay Quotes Utils', () => { const getGasBufferMock = jest.mocked(getGasBuffer); const getSlippageMock = jest.mocked(getSlippage); - const calculateTransactionGasCostMock = jest.mocked( - calculateTransactionGasCost, - ); - const { messenger, estimateGasMock, @@ -183,13 +175,6 @@ describe('Relay Quotes Utils', () => { fiatRate: '4.0', }); - calculateTransactionGasCostMock.mockReturnValue({ - fiat: '2.34', - human: '0.615', - raw: '6150000000000000', - usd: '1.23', - }); - calculateGasCostMock.mockReturnValue({ fiat: '4.56', human: '1.725', @@ -639,20 +624,341 @@ describe('Relay Quotes Utils', () => { ); }); - it('ignores requests with no target minimum', async () => { + it('ignores gas fee token requests (target=0 and source=0)', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, } as never); await getRelayQuotes({ messenger, - requests: [{ ...QUOTE_REQUEST_MOCK, targetAmountMinimum: '0' }], + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + sourceTokenAmount: '0', + }, + ], transaction: TRANSACTION_META_MOCK, }); expect(successfulFetchMock).not.toHaveBeenCalled(); }); + it('processes post-quote requests', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(successfulFetchMock).toHaveBeenCalled(); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.tradeType).toBe('EXACT_INPUT'); + expect(body.amount).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount); + }); + + it('estimates only relay transactions for post-quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const postQuoteTransaction = { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '79000', + value: '0', + }, + } as TransactionMeta; + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: postQuoteTransaction, + }); + + // Original transaction should NOT be included in gas estimation. + // Only relay step params are estimated. + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + }); + + it('prepends original transaction gas to relay gas limits for post-quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + } as TransactionMeta, + }); + + // Original tx gas (0x13498 = 79000) prepended, relay gas (21000) from params + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 79000, 21000, + ]); + }); + + it('prefers nestedTransactions gas over txParams.gas for post-quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + getGasBufferMock.mockReturnValue(1); + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + nestedTransactions: [{ gas: '0xC350' }], + } as TransactionMeta, + }); + + // nestedTransactions gas (0xC350 = 50000) used instead of txParams.gas (79000) + expect(result[0].original.metamask.gasLimits).toStrictEqual([ + 50000, 21000, + ]); + }); + + it('adds original transaction gas to EIP-7702 combined gas limit for post-quote', async () => { + const multiStepQuote = { + ...QUOTE_MOCK, + steps: [ + { + ...QUOTE_MOCK.steps[0], + items: [ + QUOTE_MOCK.steps[0].items[0], + { + ...QUOTE_MOCK.steps[0].items[0], + data: { + ...QUOTE_MOCK.steps[0].items[0].data, + gas: '30000', + }, + }, + ], + }, + ], + } as RelayQuote; + + successfulFetchMock.mockResolvedValue({ + json: async () => multiStepQuote, + } as never); + + getGasBufferMock.mockReturnValue(1); + + // EIP-7702: batch returns single combined gas for multiple relay txs + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 51000, + gasLimits: [51000], + }); + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', + value: '0', + }, + } as TransactionMeta, + }); + + // EIP-7702: original tx gas (79000) added to combined relay gas (51000) + expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]); + }); + + it('skips original transaction gas when txParams.gas is missing for post-quote', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + value: '0', + }, + } as TransactionMeta, + }); + + // No gas on txParams or nestedTransactions — only relay gas limits + expect(result[0].original.metamask.gasLimits).toStrictEqual([21000]); + }); + + it('preserves estimate vs limit distinction when using fallback gas for post-quote', async () => { + // Use a quote whose relay step has NO gas param so the single-path + // estimation is attempted; make it fail to trigger the fallback path + // where estimate (900 000) != max (1 500 000). + const noGasQuote = cloneDeep(QUOTE_MOCK); + delete noGasQuote.steps[0].items[0].data.gas; + + successfulFetchMock.mockResolvedValue({ + json: async () => noGasQuote, + } as never); + + estimateGasMock.mockRejectedValue(new Error('Estimation failed')); + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: { + ...TRANSACTION_META_MOCK, + chainId: '0x1' as Hex, + txParams: { + from: FROM_MOCK, + to: '0x9' as Hex, + data: '0xaaa' as Hex, + gas: '0x13498', // 79 000 + value: '0', + }, + } as TransactionMeta, + }); + + // Fallback: estimate=900000, max=1500000. + // With originalTxGas=79000 added independently: + // estimate call should receive 900000+79000 = 979000 + // max call should receive 1500000+79000 = 1579000 + const estimateCall = calculateGasCostMock.mock.calls.find( + ([args]) => !args.isMax, + ); + const maxCall = calculateGasCostMock.mock.calls.find( + ([args]) => args.isMax, + ); + + expect(estimateCall?.[0].gas).toBe(979000); + expect(maxCall?.[0].gas).toBe(1579000); + }); + + it('does not prepend original transaction for post-quote when txParams.to is missing', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + // With no txParams.to the original tx should be skipped, so only + // the relay step params are sent to gas estimation (single path). + expect(estimateGasBatchMock).not.toHaveBeenCalled(); + }); + + it('sets isSourceGasFeeToken for post-quote when insufficient native balance', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + getTokenBalanceMock.mockReturnValue('0'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + it('includes duration in quote', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, @@ -684,6 +990,36 @@ describe('Relay Quotes Utils', () => { }); }); + it('sets provider fee to zero when subsidized fee is present', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.fees.subsidized = { + amount: '500000', + amountFormatted: '0.50', + amountUsd: '0.50', + currency: { + address: '0xdef' as Hex, + chainId: 1, + decimals: 6, + }, + minimumAmount: '500000', + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.provider).toStrictEqual({ + usd: '0', + fiat: '0', + }); + }); + it('includes dust in quote', async () => { successfulFetchMock.mockResolvedValue({ json: async () => QUOTE_MOCK, @@ -1078,8 +1414,136 @@ describe('Relay Quotes Utils', () => { }); expect(result[0].targetAmount).toStrictEqual({ - human: QUOTE_MOCK.details.currencyOut.amountFormatted, - raw: QUOTE_MOCK.details.currencyOut.amount, + usd: '1.23', + fiat: '2.46', + }); + }); + + it('adds subsidized fee to target amount fiat values when trade type is EXACT_INPUT', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.fees.subsidized = { + amount: '500000000000000', + amountFormatted: '0.0005', + amountUsd: '0.50', + currency: { + address: '0xdef' as Hex, + chainId: 1, + decimals: 18, + }, + minimumAmount: '500000000000000', + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount).toStrictEqual({ + usd: '1.73', + fiat: '3.46', + }); + }); + + it('uses amountFormatted for subsidized fee when fee token is a stablecoin', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.fees.subsidized = { + amount: '500000', + amountFormatted: '0.50', + amountUsd: '0.49', + currency: { + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, + chainId: 1, + decimals: 6, + }, + minimumAmount: '500000', + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount).toStrictEqual({ + usd: '1.73', + fiat: '3.46', + }); + }); + + it('does not add subsidized fee to target amount when trade type is not EXACT_INPUT', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.fees.subsidized = { + amount: '500000000000000', + amountFormatted: '0.0005', + amountUsd: '0.50', + currency: { + address: '0xdef' as Hex, + chainId: 1, + decimals: 18, + }, + minimumAmount: '500000000000000', + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => quoteMock, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount).toStrictEqual({ + usd: '1.23', + fiat: '2.46', + }); + }); + + it('uses amountFormatted as usd for target amount when target is a stablecoin', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount).toStrictEqual({ + usd: '1', + fiat: '2', + }); + }); + + it('uses amountUsd for target amount when target is not a stablecoin', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].targetAmount).toStrictEqual({ usd: '1.23', fiat: '2.46', }); @@ -1143,6 +1607,33 @@ describe('Relay Quotes Utils', () => { ); }); + it('does not convert to Hyperliquid deposit for post-quote requests targeting Arbitrum USDC', async () => { + const postQuoteRequest: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + targetAmountMinimum: '0', + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger, + requests: [postQuoteRequest], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.destinationChainId).toBe(Number(CHAIN_ID_ARBITRUM)); + expect(body.destinationCurrency).toBe(ARBITRUM_USDC_ADDRESS); + }); + it('updates request if source is polygon native', async () => { getNativeTokenMock.mockReturnValue( '0x0000000000000000000000000000000000001010', diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 730957ff492..e7097a8bbbd 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -6,7 +6,7 @@ import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { CHAIN_ID_HYPERCORE, TOKEN_TRANSFER_FOUR_BYTE } from './constants'; +import { TOKEN_TRANSFER_FOUR_BYTE } from './constants'; import type { RelayQuote, RelayQuoteRequest } from './types'; import { TransactionPayStrategy } from '../..'; import type { @@ -16,8 +16,10 @@ import type { import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM, + CHAIN_ID_HYPERCORE, CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS, + STABLECOINS, } from '../../constants'; import { projectLogger } from '../../logger'; import type { @@ -59,8 +61,13 @@ export async function getRelayQuotes( try { const normalizedRequests = requests - // Ignore gas fee token requests - .filter((singleRequest) => singleRequest.targetAmountMinimum !== '0') + // Ignore gas fee token requests (which have both target=0 and source=0) + // but keep post-quote requests (identified by isPostQuote flag) + .filter( + (singleRequest) => + singleRequest.targetAmountMinimum !== '0' || + singleRequest.isPostQuote, + ) .map((singleRequest) => normalizeRequest(singleRequest)); log('Normalized requests', normalizedRequests); @@ -111,19 +118,29 @@ async function getSingleQuote( ); try { + // For post-quote or max amount flows, use EXACT_INPUT - user specifies how much to send, + // and we show them how much they'll receive after fees. + // For regular flows with a target amount, use EXPECTED_OUTPUT. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const useExactInput = isMaxAmount || request.isPostQuote; + const body: RelayQuoteRequest = { - amount: isMaxAmount ? sourceTokenAmount : targetAmountMinimum, + amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, destinationChainId: Number(targetChainId), destinationCurrency: targetTokenAddress, originChainId: Number(sourceChainId), originCurrency: sourceTokenAddress, recipient: from, slippageTolerance, - tradeType: isMaxAmount ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', + tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', user: from, }; - await processTransactions(transaction, request, body, messenger); + // Skip transaction processing for post-quote flows - the original transaction + // will be included in the batch separately, not as part of the quote + if (!request.isPostQuote) { + await processTransactions(transaction, request, body, messenger); + } const url = getFeatureFlags(messenger).relayQuoteUrl; @@ -140,7 +157,7 @@ async function getSingleQuote( log('Fetched relay quote', quote); - return normalizeQuote(quote, request, fullRequest); + return await normalizeQuote(quote, request, fullRequest); } catch (error) { log('Error fetching relay quote', error); throw error; @@ -247,6 +264,7 @@ function normalizeRequest(request: QuoteRequest): QuoteRequest { }; const isHyperliquidDeposit = + !request.isPostQuote && request.targetChainId === CHAIN_ID_ARBITRUM && request.targetTokenAddress.toLowerCase() === ARBITRUM_USDC_ADDRESS.toLowerCase(); @@ -299,16 +317,22 @@ async function normalizeQuote( usdToFiatRate, ); - const provider = getFiatValueFromUsd( - calculateProviderFee(quote), - usdToFiatRate, - ); + const subsidizedFeeUsd = getSubsidizedFeeAmountUsd(quote); + + const provider = subsidizedFeeUsd.gt(0) + ? { usd: '0', fiat: '0' } + : getFiatValueFromUsd(calculateProviderFee(quote), usdToFiatRate); const { gasLimits, isGasFeeToken: isSourceGasFeeToken, ...sourceNetwork - } = await calculateSourceNetworkCost(quote, messenger, request); + } = await calculateSourceNetworkCost( + quote, + messenger, + request, + fullRequest.transaction, + ); const targetNetwork = { usd: '0', @@ -321,11 +345,30 @@ async function normalizeQuote( ...getFiatValueFromUsd(new BigNumber(currencyIn.amountUsd), usdToFiatRate), }; - const targetAmount: Amount = { - human: currencyOut.amountFormatted, - raw: currencyOut.amount, - ...getFiatValueFromUsd(new BigNumber(currencyOut.amountUsd), usdToFiatRate), - }; + const isTargetStablecoin = isStablecoin( + request.targetChainId, + request.targetTokenAddress, + ); + + const additionalTargetAmountUsd = + quote.request.tradeType === 'EXACT_INPUT' + ? subsidizedFeeUsd + : new BigNumber(0); + + if (additionalTargetAmountUsd.gt(0)) { + log( + 'Including subsidized fee in target amount', + additionalTargetAmountUsd.toString(10), + ); + } + + const baseTargetAmountUsd = isTargetStablecoin + ? new BigNumber(currencyOut.amountFormatted) + : new BigNumber(currencyOut.amountUsd); + + const targetAmountUsd = baseTargetAmountUsd.plus(additionalTargetAmountUsd); + + const targetAmount = getFiatValueFromUsd(targetAmountUsd, usdToFiatRate); const metamask = { gasLimits, @@ -433,15 +476,23 @@ function getFiatRates( /** * Calculates source network cost from a Relay quote. * + * For post-quote flows (e.g. predictWithdraw), the cost also includes the + * original transaction's gas (the user's Polygon USDC.e transfer) in addition + * to the Relay deposit transaction gas, by appending the original + * transaction's params so that gas estimation and gas-fee-token logic handle + * both transactions together. + * * @param quote - Relay quote. * @param messenger - Controller messenger. * @param request - Quote request. + * @param transaction - Original transaction metadata. * @returns Total source network cost in USD and fiat. */ async function calculateSourceNetworkCost( quote: RelayQuote, messenger: TransactionPayControllerMessenger, request: QuoteRequest, + transaction: TransactionMeta, ): Promise< TransactionPayQuote['fees']['sourceNetwork'] & { gasLimits: number[]; @@ -450,17 +501,21 @@ async function calculateSourceNetworkCost( > { const { from, sourceChainId, sourceTokenAddress } = request; - const allParams = quote.steps + const relayParams = quote.steps .flatMap((step) => step.items) .map((item) => item.data); const { relayDisabledGasStationChains } = getFeatureFlags(messenger); const { chainId, data, maxFeePerGas, maxPriorityFeePerGas, to, value } = - allParams[0]; + relayParams[0]; const { totalGasEstimate, totalGasLimit, gasLimits } = - await calculateSourceNetworkGasLimit(allParams, messenger); + await calculateSourceNetworkGasLimit( + relayParams, + messenger, + request.isPostQuote ? transaction : undefined, + ); log('Gas limit', { totalGasEstimate, @@ -556,7 +611,10 @@ async function calculateSourceNetworkCost( let finalAmount = gasFeeToken.amount; - if (allParams.length > 1) { + const hasMultipleTransactions = + relayParams.length > 1 || gasLimits.length > 1; + + if (hasMultipleTransactions) { const gasRate = new BigNumber(gasFeeToken.amount, 16).dividedBy( gasFeeToken.gas, 16, @@ -598,25 +656,95 @@ async function calculateSourceNetworkCost( } /** - * Calculate the total gas limit for the source network transactions. + * Calculate the total gas limit for the source network. * - * @param params - Array of transaction parameters. + * For post-quote flows (e.g. predict withdrawals), the original transaction's + * gas is combined with the relay gas so that source network cost accounts for + * both the user's transaction and the relay transactions. + * + * @param params - Array of relay transaction parameters. * @param messenger - Controller messenger. - * @returns - Total gas limit. + * @param postQuoteTransaction - Original transaction for post-quote flows. + * When provided, its gas is included in the returned totals. + * @returns Total gas estimates and per-transaction gas limits. */ async function calculateSourceNetworkGasLimit( params: RelayQuote['steps'][0]['items'][0]['data'][], messenger: TransactionPayControllerMessenger, + postQuoteTransaction?: TransactionMeta, ): Promise<{ totalGasEstimate: number; totalGasLimit: number; gasLimits: number[]; }> { - if (params.length === 1) { - return calculateSourceNetworkGasLimitSingle(params[0], messenger); + const relayGas = + params.length === 1 + ? await calculateSourceNetworkGasLimitSingle(params[0], messenger) + : await calculateSourceNetworkGasLimitBatch(params, messenger); + + if (!postQuoteTransaction?.txParams.to) { + return relayGas; + } + + return combinePostQuoteGas(relayGas, params.length, postQuoteTransaction); +} + +/** + * Combine the original transaction's gas with relay gas for post-quote flows. + * + * Prefers gas from `nestedTransactions` (preserves the caller-provided value) + * since TransactionController may re-estimate `txParams.gas` during batch + * creation. + * + * @param relayGas - Gas estimates from relay transactions. + * @param relayGas.totalGasEstimate - Estimated gas total. + * @param relayGas.totalGasLimit - Maximum gas total. + * @param relayGas.gasLimits - Per-transaction gas limits. + * @param relayParamCount - Number of relay transaction parameters. + * @param transaction - Original transaction metadata. + * @returns Combined gas estimates including the original transaction. + */ +function combinePostQuoteGas( + relayGas: { + totalGasEstimate: number; + totalGasLimit: number; + gasLimits: number[]; + }, + relayParamCount: number, + transaction: TransactionMeta, +): { totalGasEstimate: number; totalGasLimit: number; gasLimits: number[] } { + const nestedGas = transaction.nestedTransactions?.find((tx) => tx.gas)?.gas; + const rawGas = nestedGas ?? transaction.txParams.gas; + const originalTxGas = rawGas ? new BigNumber(rawGas).toNumber() : undefined; + + if (originalTxGas === undefined) { + return relayGas; + } + + let { gasLimits } = relayGas; + const isEIP7702 = gasLimits.length === 1 && relayParamCount > 1; + + if (isEIP7702) { + // EIP-7702: single combined gas limit — add the original tx gas + // so the atomic batch covers both relay and original transactions. + gasLimits = [gasLimits[0] + originalTxGas]; + } else { + // Non-7702: individual gas limits — prepend the original tx gas + // so the list order matches relay-submit's transaction order. + gasLimits = [originalTxGas, ...gasLimits]; } - return calculateSourceNetworkGasLimitBatch(params, messenger); + const totalGasEstimate = relayGas.totalGasEstimate + originalTxGas; + const totalGasLimit = relayGas.totalGasLimit + originalTxGas; + + log('Combined original tx gas with relay gas', { + originalTxGas, + isEIP7702, + gasLimits, + totalGasLimit, + }); + + return { totalGasEstimate, totalGasLimit, gasLimits }; } /** @@ -836,3 +964,26 @@ async function calculateSourceNetworkGasLimitBatch( gasLimits, }; } + +function getSubsidizedFeeAmountUsd(quote: RelayQuote): BigNumber { + const subsidizedFee = quote.fees?.subsidized; + const amountUsd = new BigNumber(subsidizedFee?.amountUsd ?? '0'); + const amountFormatted = new BigNumber(subsidizedFee?.amountFormatted ?? '0'); + + if (!subsidizedFee || amountUsd.isZero()) { + return new BigNumber(0); + } + + const isSubsidizedStablecoin = isStablecoin( + toHex(subsidizedFee.currency.chainId), + subsidizedFee.currency.address, + ); + + return isSubsidizedStablecoin ? amountFormatted : amountUsd; +} + +function isStablecoin(chainId: string, tokenAddress: string): boolean { + return Boolean( + STABLECOINS[chainId as Hex]?.includes(tokenAddress.toLowerCase() as Hex), + ); +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 1f8a29fae4a..aa45ae94639 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -492,6 +492,141 @@ describe('Relay Submit Utils', () => { ]); }); + describe('post-quote flow', () => { + beforeEach(() => { + request.quotes[0].request.isPostQuote = true; + request.transaction = { + id: ORIGINAL_TRANSACTION_ID_MOCK, + txParams: { + from: FROM_MOCK, + to: '0xrecipient' as Hex, + data: '0xorigdata' as Hex, + value: '0x100' as Hex, + }, + type: TransactionType.simpleSend, + } as TransactionMeta; + }); + + it('adds transaction batch with original transaction prepended', async () => { + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + gasFeeToken: undefined, + networkClientId: NETWORK_CLIENT_ID_MOCK, + origin: ORIGIN_METAMASK, + overwriteUpgrade: true, + requireApproval: false, + transactions: [ + { + params: expect.objectContaining({ + data: '0xorigdata', + to: '0xrecipient', + value: '0x100', + }), + type: TransactionType.simpleSend, + }, + { + params: expect.objectContaining({ + data: '0x1234', + to: '0xfedcb', + value: '0x4d2', + }), + type: TransactionType.relayDeposit, + }, + ], + }), + ); + }); + + it('assigns correct transaction types with multi-step relay (approve + deposit)', async () => { + // Add a second item to simulate approve + deposit from the relay + request.quotes[0].original.steps[0].items.push({ + ...request.quotes[0].original.steps[0].items[0], + data: { + ...request.quotes[0].original.steps[0].items[0].data, + data: '0xapprove' as Hex, + to: '0xapproveTarget' as Hex, + }, + }); + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + + const { transactions } = addTransactionBatchMock.mock + .calls[0][0] as unknown as Record; + + expect(transactions).toHaveLength(3); + expect(transactions[0]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.simpleSend, + }), + ); + expect(transactions[1]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.tokenMethodApprove, + }), + ); + expect(transactions[2]).toStrictEqual( + expect.objectContaining({ + type: TransactionType.relayDeposit, + }), + ); + }); + + it('sets gas to undefined when gasLimits entry is missing', async () => { + request.quotes[0].original.metamask.gasLimits = []; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + gas: undefined, + }), + type: TransactionType.relayDeposit, + }), + ]), + }), + ); + }); + + it('does not activate 7702 mode with post-quote gas limits', async () => { + // gasLimits covers both original tx and relay step. + request.quotes[0].original.metamask.gasLimits = [21000, 21000]; + + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + disable7702: true, + disableHook: false, + disableSequential: false, + gasLimit7702: undefined, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + gas: expect.any(String), + }), + type: TransactionType.simpleSend, + }), + expect.objectContaining({ + params: expect.objectContaining({ + gas: expect.any(String), + }), + type: TransactionType.relayDeposit, + }), + ], + }), + ); + }); + }); + it('adds transaction batch with single gasLimit7702', async () => { request.quotes[0].original.steps[0].items.push({ ...request.quotes[0].original.steps[0].items[0], diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 2b8b1c8d96f..6819a7a5122 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -32,6 +32,39 @@ const FALLBACK_HASH = '0x0' as Hex; const log = createModuleLogger(projectLogger, 'relay-strategy'); +/** + * Determine the transaction type for a given index in the batch. + * + * @param isPostQuote - Whether this is a post-quote flow. + * @param index - Index of the transaction in the batch. + * @param originalType - Type of the original transaction (used for post-quote index 0). + * @param relayParamCount - Number of relay-only params (excludes prepended original tx). + * @returns The transaction type. + */ +function getTransactionType( + isPostQuote: boolean | undefined, + index: number, + originalType: TransactionMeta['type'], + relayParamCount: number, +): TransactionMeta['type'] { + // Post-quote index 0 is the original transaction + if (isPostQuote && index === 0) { + return originalType; + } + + // Adjust index for post-quote flows where original tx is prepended + const relayIndex = isPostQuote ? index - 1 : index; + + // Single relay step is always a deposit (no approval needed) + if (relayParamCount === 1) { + return TransactionType.relayDeposit; + } + + return relayIndex === 0 + ? TransactionType.tokenMethodApprove + : TransactionType.relayDeposit; +} + /** * Submits Relay quotes. * @@ -84,7 +117,7 @@ async function executeSingleQuote( }, ); - await submitTransactions(quote, transaction.id, messenger); + await submitTransactions(quote, transaction, messenger); const targetHash = await waitForRelayCompletion(quote.original); @@ -173,13 +206,13 @@ function normalizeParams( * Submit transactions for a relay quote. * * @param quote - Relay quote. - * @param parentTransactionId - ID of the parent transaction. + * @param transaction - Original transaction meta. * @param messenger - Controller messenger. * @returns Hash of the last submitted transaction. */ async function submitTransactions( quote: TransactionPayQuote, - parentTransactionId: string, + transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, ): Promise { const { steps } = quote.original; @@ -194,6 +227,24 @@ async function submitTransactions( normalizeParams(singleParams, messenger), ); + // For post-quote flows, prepend the original transaction so it gets + // included in the batch alongside the relay deposit(s). + // This always results in multiple params, so it takes the batch path. + const { isPostQuote } = quote.request; + + const allParams = + isPostQuote && transaction.txParams.to + ? [ + { + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams, + ...normalizedParams, + ] + : normalizedParams; + const transactionIds: string[] = []; const { from, sourceChainId, sourceTokenAddress } = quote.request; @@ -203,7 +254,7 @@ async function submitTransactions( ); log('Adding transactions', { - normalizedParams, + normalizedParams: allParams, sourceChainId, from, networkClientId, @@ -218,7 +269,7 @@ async function submitTransactions( updateTransaction( { - transactionId: parentTransactionId, + transactionId: transaction.id, messenger, note: 'Add required transaction ID from Relay submission', }, @@ -250,9 +301,9 @@ async function submitTransactions( const { gasLimits } = quote.original.metamask; - if (params.length === 1) { + if (allParams.length === 1) { const transactionParams = { - ...normalizedParams[0], + ...allParams[0], authorizationList, gas: toHex(gasLimits[0]), }; @@ -270,22 +321,32 @@ async function submitTransactions( ); } else { const gasLimit7702 = - gasLimits.length === 1 ? toHex(gasLimits[0]) : undefined; - - const transactions = normalizedParams.map((singleParams, index) => ({ - params: { - data: singleParams.data as Hex, - gas: gasLimit7702 ? undefined : toHex(gasLimits[index]), - maxFeePerGas: singleParams.maxFeePerGas as Hex, - maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, - to: singleParams.to as Hex, - value: singleParams.value as Hex, - }, - type: - index === 0 - ? TransactionType.tokenMethodApprove - : TransactionType.relayDeposit, - })); + gasLimits.length === 1 && normalizedParams.length > 1 + ? toHex(gasLimits[0]) + : undefined; + + const transactions = allParams.map((singleParams, index) => { + const gasLimit = gasLimits[index]; + const gas = + gasLimit === undefined || gasLimit7702 ? undefined : toHex(gasLimit); + + return { + params: { + data: singleParams.data as Hex, + gas, + maxFeePerGas: singleParams.maxFeePerGas as Hex, + maxPriorityFeePerGas: singleParams.maxPriorityFeePerGas as Hex, + to: singleParams.to as Hex, + value: singleParams.value as Hex, + }, + type: getTransactionType( + isPostQuote, + index, + transaction.type, + normalizedParams.length, + ), + }; + }); await messenger.call('TransactionController:addTransactionBatch', { from, diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index 71a3b565f69..911ed1788d5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -56,6 +56,17 @@ export type RelayQuote = { relayer: { amountUsd: string; }; + subsidized?: { + amount: string; + amountFormatted: string; + amountUsd: string; + currency: { + address: Hex; + chainId: number; + decimals: number; + }; + minimumAmount: string; + }; }; metamask: { gasLimits: number[]; diff --git a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts index a6ed67f3c1a..83387f9fdc6 100644 --- a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts @@ -69,8 +69,6 @@ describe('TestStrategy', () => { strategy: TransactionPayStrategy.Test, targetAmount: { fiat: expect.any(String), - human: expect.any(String), - raw: expect.any(String), usd: expect.any(String), }, }, diff --git a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts index 06b470bcb8e..4ff07dd8268 100644 --- a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts @@ -55,9 +55,7 @@ export class TestStrategy implements PayStrategy { usd: '4.56', }, targetAmount: { - human: '5.67', fiat: '5.67', - raw: '567000', usd: '5.67', }, strategy: TransactionPayStrategy.Test, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 8539a6e15ff..794d3270ca0 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -2,7 +2,6 @@ import type { CurrencyRateControllerActions, TokenBalancesControllerGetStateAction, } from '@metamask/assets-controllers'; -import type { TokenListControllerActions } from '@metamask/assets-controllers'; import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; import type { TokensControllerGetStateAction } from '@metamask/assets-controllers'; import type { AccountTrackerControllerGetStateAction } from '@metamask/assets-controllers'; @@ -47,7 +46,6 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction - | TokenListControllerActions | TokenRatesControllerGetStateAction | TokensControllerGetStateAction | TransactionControllerAddTransactionAction @@ -85,12 +83,28 @@ export type TransactionPayControllerUpdatePaymentTokenAction = { handler: (request: UpdatePaymentTokenRequest) => void; }; -/** Action to set the max amount flag for a transaction. */ -export type TransactionPayControllerSetIsMaxAmountAction = { - type: `${typeof CONTROLLER_NAME}:setIsMaxAmount`; - handler: (transactionId: string, isMaxAmount: boolean) => void; +/** Action to update transaction configuration using a callback. */ +export type TransactionPayControllerSetTransactionConfigAction = { + type: `${typeof CONTROLLER_NAME}:setTransactionConfig`; + handler: (transactionId: string, callback: TransactionConfigCallback) => void; }; +/** Configurable properties of a transaction. */ +export type TransactionConfig = { + /** Whether the user has selected the maximum amount. */ + isMaxAmount?: boolean; + + /** + * Whether this is a post-quote transaction. + * When true, the paymentToken represents the destination token, + * and the quote source is derived from the transaction's output token. + */ + isPostQuote?: boolean; +}; + +/** Callback to update transaction config. */ +export type TransactionConfigCallback = (config: TransactionConfig) => void; + export type TransactionPayControllerStateChangeEvent = ControllerStateChangeEvent< typeof CONTROLLER_NAME, @@ -101,7 +115,7 @@ export type TransactionPayControllerActions = | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction - | TransactionPayControllerSetIsMaxAmountAction + | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction; export type TransactionPayControllerEvents = @@ -142,7 +156,20 @@ export type TransactionData = { /** Whether the user has selected the maximum amount. */ isMaxAmount?: boolean; - /** Source token selected for the transaction. */ + /** + * Whether this is a post-quote transaction. + * When true, the paymentToken represents the destination token, + * and the quote source is derived from the transaction's output token. + * Used when funds need to be moved after a transaction completes + * (e.g., bridging output to a different token/chain). + */ + isPostQuote?: boolean; + + /** + * Token selected for the transaction. + * - For standard flows (isPostQuote=false): This is the SOURCE/payment token + * - For post-quote flows (isPostQuote=true): This is the DESTINATION token + */ paymentToken?: TransactionPaymentToken; /** Quotes retrieved for the transaction. */ @@ -214,7 +241,16 @@ export type TransactionPaySourceAmount = { /** Amount of payment token required in atomic format without factoring token decimals. */ sourceAmountRaw: string; - /** Address of the required token. */ + /** Balance of the source token in atomic format (for post-quote flows). */ + sourceBalanceRaw?: string; + + /** Chain ID of the source token (for post-quote flows). */ + sourceChainId?: Hex; + + /** Address of the source token (for post-quote flows). */ + sourceTokenAddress?: Hex; + + /** Address of the target token. */ targetTokenAddress: Hex; }; @@ -270,6 +306,9 @@ export type QuoteRequest = { /** Whether the transaction is a maximum amount transaction. */ isMaxAmount?: boolean; + /** Whether this is a post-quote flow. */ + isPostQuote?: boolean; + /** Balance of the source token in atomic format without factoring token decimals. */ sourceBalanceRaw: string; @@ -337,7 +376,7 @@ export type TransactionPayQuote = { strategy: TransactionPayStrategy; /** Amount of target token provided. */ - targetAmount: Amount; + targetAmount: FiatValue; }; /** Request to get quotes for a transaction. */ @@ -432,7 +471,7 @@ export type TransactionPayTotals = { sourceAmount: Amount; /** Total amount of target token provided. */ - targetAmount: Amount; + targetAmount: FiatValue; /** Overall total cost for the target transaction and all quotes. */ total: FiatValue; diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index a247cef45d1..3d2d6107062 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -75,8 +75,6 @@ const TOTALS_MOCK = { }, targetAmount: { fiat: '5.67', - human: '5.67', - raw: '567000', usd: '6.78', }, total: { @@ -413,4 +411,139 @@ describe('Quotes Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(0); }); }); + + describe('post-quote (withdrawal) flow', () => { + const DESTINATION_TOKEN_MOCK: TransactionPaymentToken = { + address: '0xdef' as Hex, + balanceFiat: '100.00', + balanceHuman: '1.00', + balanceRaw: '1000000000000000000', + balanceUsd: '100.00', + chainId: '0x38', + decimals: 18, + symbol: 'BNB', + }; + + const SOURCE_TOKEN_MOCK: TransactionPayRequiredToken = { + address: '0x456' as Hex, + amountHuman: '10', + amountRaw: '10000000', + balanceRaw: '50000000', + chainId: '0x89' as Hex, + decimals: 6, + symbol: 'USDC.e', + skipIfBalance: false, + } as TransactionPayRequiredToken; + + const POST_QUOTE_TRANSACTION_DATA: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + sourceAmounts: [ + { + sourceAmountHuman: '10', + sourceAmountRaw: '10000000', + sourceBalanceRaw: SOURCE_TOKEN_MOCK.balanceRaw, + sourceChainId: SOURCE_TOKEN_MOCK.chainId, + sourceTokenAddress: SOURCE_TOKEN_MOCK.address, + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + } as TransactionPaySourceAmount, + ], + tokens: [SOURCE_TOKEN_MOCK], + }; + + it('builds post-quote request with paymentToken as target', async () => { + await run({ + transactionData: POST_QUOTE_TRANSACTION_DATA, + }); + + expect(getQuotesMock).toHaveBeenCalledWith({ + messenger, + requests: [ + { + from: TRANSACTION_META_MOCK.txParams.from, + isMaxAmount: false, + isPostQuote: true, + sourceBalanceRaw: SOURCE_TOKEN_MOCK.balanceRaw, + sourceChainId: SOURCE_TOKEN_MOCK.chainId, + sourceTokenAddress: SOURCE_TOKEN_MOCK.address, + sourceTokenAmount: '10000000', + targetAmountMinimum: '0', + targetChainId: DESTINATION_TOKEN_MOCK.chainId, + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + }); + + it('does not fetch quotes when sourceAmounts is empty (same-token filtered in source-amounts)', async () => { + const sameTokenData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + // Same-token-same-chain cases are filtered out in source-amounts.ts, + // so sourceAmounts would be empty + sourceAmounts: [], + }; + + await run({ + transactionData: sameTokenData, + }); + + // When requests array is empty, getQuotes is not called + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + + it('does not fetch quotes if sourceAmounts missing required fields', async () => { + const noSourceTokenData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + sourceAmounts: [ + { + sourceAmountHuman: '10', + sourceAmountRaw: '10000000', + // Missing sourceTokenAddress, sourceChainId, sourceBalanceRaw + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + } as TransactionPaySourceAmount, + ], + }; + + await run({ + transactionData: noSourceTokenData, + }); + + // When sourceAmounts missing required fields, requests array is empty + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty requests if no paymentToken', async () => { + const noPaymentTokenData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + paymentToken: undefined, + }; + + await run({ + transactionData: noPaymentTokenData, + }); + + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + + it('does not fetch quotes when no matching sourceAmount found', async () => { + const noMatchingSourceAmountData: TransactionData = { + ...POST_QUOTE_TRANSACTION_DATA, + sourceAmounts: [ + { + sourceAmountRaw: '99999', + targetTokenAddress: '0xdifferent' as Hex, + } as TransactionPaySourceAmount, + ], + }; + + await run({ + transactionData: noMatchingSourceAmountData, + }); + + // When no matching sourceAmount is found, requests array is empty + expect(getQuotesMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index a242607fbf7..6bdcbc3f8c5 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -55,11 +55,13 @@ export async function updateQuotes( log('Updating quotes', { transactionId }); - const { isMaxAmount, paymentToken, sourceAmounts, tokens } = transactionData; + const { isMaxAmount, isPostQuote, paymentToken, sourceAmounts, tokens } = + transactionData; const requests = buildQuoteRequests({ from: transaction.txParams.from as Hex, isMaxAmount: isMaxAmount ?? false, + isPostQuote, paymentToken, sourceAmounts, tokens, @@ -89,6 +91,7 @@ export async function updateQuotes( syncTransaction({ batchTransactions, + isPostQuote, messenger: messenger as never, paymentToken, totals, @@ -114,19 +117,22 @@ export async function updateQuotes( * * @param request - Request object. * @param request.batchTransactions - Batch transactions to sync. + * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.messenger - Messenger instance. - * @param request.paymentToken - Payment token used. + * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). * @param request.totals - Calculated totals. * @param request.transactionId - ID of the transaction to sync. */ function syncTransaction({ batchTransactions, + isPostQuote, messenger, paymentToken, totals, transactionId, }: { batchTransactions: BatchTransaction[]; + isPostQuote?: boolean; messenger: TransactionPayControllerMessenger; paymentToken: TransactionPaymentToken | undefined; totals: TransactionPayTotals; @@ -149,6 +155,7 @@ function syncTransaction({ tx.metamaskPay = { bridgeFeeFiat: totals.fees.provider.usd, chainId: paymentToken.chainId, + isPostQuote, networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, targetFiat: totals.targetAmount.usd, tokenAddress: paymentToken.address, @@ -213,7 +220,8 @@ export async function refreshQuotes( * @param request - Request parameters. * @param request.from - Address from which the transaction is sent. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. - * @param request.paymentToken - Payment token used for the transaction. + * @param request.isPostQuote - Whether this is a post-quote flow. + * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). * @param request.sourceAmounts - Source amounts for the transaction. * @param request.tokens - Required tokens for the transaction. * @param request.transactionId - ID of the transaction. @@ -222,6 +230,7 @@ export async function refreshQuotes( function buildQuoteRequests({ from, isMaxAmount, + isPostQuote, paymentToken, sourceAmounts, tokens, @@ -229,6 +238,7 @@ function buildQuoteRequests({ }: { from: Hex; isMaxAmount: boolean; + isPostQuote?: boolean; paymentToken: TransactionPaymentToken | undefined; sourceAmounts: TransactionPaySourceAmount[] | undefined; tokens: TransactionPayRequiredToken[]; @@ -238,6 +248,19 @@ function buildQuoteRequests({ return []; } + if (isPostQuote) { + // Post-quote flow: source = transaction's required token, target = paymentToken (destination) + // The user wants to receive the transaction output in paymentToken + return buildPostQuoteRequests({ + from, + isMaxAmount, + destinationToken: paymentToken, + sourceAmounts, + transactionId, + }); + } + + // Standard flow: source = paymentToken, target = required tokens const requests = (sourceAmounts ?? []).map((sourceAmount) => { const token = tokens.find( (singleToken) => singleToken.address === sourceAmount.targetTokenAddress, @@ -263,6 +286,73 @@ function buildQuoteRequests({ return requests; } +/** + * Build quote requests for post-quote flows. + * In this flow, the source is the transaction's required token, + * and the target is the user's selected destination token (paymentToken). + * + * @param request - Request parameters. + * @param request.from - Address from which the transaction is sent. + * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. + * @param request.destinationToken - Destination token (paymentToken in post-quote mode). + * @param request.sourceAmounts - Source amounts for the transaction (includes source token info). + * @param request.transactionId - ID of the transaction. + * @returns Array of quote requests for post-quote flow. + */ +function buildPostQuoteRequests({ + from, + isMaxAmount, + destinationToken, + sourceAmounts, + transactionId, +}: { + from: Hex; + isMaxAmount: boolean; + destinationToken: TransactionPaymentToken; + sourceAmounts: TransactionPaySourceAmount[] | undefined; + transactionId: string; +}): QuoteRequest[] { + // Find the source amount where targetTokenAddress matches the destination token + const sourceAmount = sourceAmounts?.find( + (amount) => + amount.targetTokenAddress.toLowerCase() === + destinationToken.address.toLowerCase(), + ); + + // Same-token-same-chain cases are already filtered in source-amounts.ts + if ( + !sourceAmount?.sourceBalanceRaw || + !sourceAmount.sourceChainId || + !sourceAmount.sourceTokenAddress + ) { + log('No valid source amount found for post-quote request', { + transactionId, + }); + return []; + } + + const request: QuoteRequest = { + from, + isMaxAmount, + isPostQuote: true, + sourceBalanceRaw: sourceAmount.sourceBalanceRaw, + sourceTokenAmount: sourceAmount.sourceAmountRaw, + sourceChainId: sourceAmount.sourceChainId, + sourceTokenAddress: sourceAmount.sourceTokenAddress, + // For post-quote flows, use EXACT_INPUT - user specifies how much to send, + // and we show them how much they'll receive after fees + targetAmountMinimum: '0', + targetChainId: destinationToken.chainId, + targetTokenAddress: destinationToken.address, + }; + + log('Post-quote request built', { transactionId, request }); + + // Currently only single token post-quote flows are supported. + // Multiple token support would require multiple quotes for each required token. + return [request]; +} + /** * Retrieve quotes for a transaction. * diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.ts b/packages/transaction-pay-controller/src/utils/required-tokens.ts index ca5db2ceab7..b1812745f59 100644 --- a/packages/transaction-pay-controller/src/utils/required-tokens.ts +++ b/packages/transaction-pay-controller/src/utils/required-tokens.ts @@ -285,7 +285,7 @@ function getTokenTransferData(transactionMeta: TransactionMeta): ); const nestedCall = - nestedCallIndex === undefined + nestedCallIndex === undefined || nestedCallIndex === -1 ? undefined : nestedTransactions?.[nestedCallIndex]; diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index 9612a79e032..a6d0841367f 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -7,7 +7,10 @@ import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import type { TransactionData, TransactionPayRequiredToken } from '../types'; -jest.mock('./token'); +jest.mock('./token', () => ({ + ...jest.requireActual('./token'), + getTokenFiatRate: jest.fn(), +})); jest.mock('./transaction'); const PAYMENT_TOKEN_MOCK: TransactionPaymentToken = { @@ -209,5 +212,142 @@ describe('Source Amounts Utils', () => { it('does nothing if no transaction data', () => { updateSourceAmounts(TRANSACTION_ID_MOCK, undefined, messenger); }); + + describe('post-quote (withdrawal) flow', () => { + const DESTINATION_TOKEN_MOCK = { + address: '0xdef' as const, + balanceFiat: '100.00', + balanceHuman: '1.00', + balanceRaw: '1000000000000000000', + balanceUsd: '100.00', + chainId: '0x38' as const, + decimals: 18, + symbol: 'BNB', + }; + + it('calculates source amounts from tokens for post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + skipIfBalance: false, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: TRANSACTION_TOKEN_MOCK.amountHuman, + sourceAmountRaw: TRANSACTION_TOKEN_MOCK.amountRaw, + sourceBalanceRaw: TRANSACTION_TOKEN_MOCK.balanceRaw, + sourceChainId: TRANSACTION_TOKEN_MOCK.chainId, + sourceTokenAddress: TRANSACTION_TOKEN_MOCK.address, + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + }, + ]); + }); + + it('filters out skipIfBalance tokens in post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + skipIfBalance: true, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('does nothing for post-quote if no paymentToken', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toBeUndefined(); + }); + + it('filters out zero amount tokens in post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + amountRaw: '0', + skipIfBalance: false, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('filters out same token on same chain in post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + address: DESTINATION_TOKEN_MOCK.address, + chainId: DESTINATION_TOKEN_MOCK.chainId, + skipIfBalance: false, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('uses token balance when isMaxAmount is true in post-quote flow', () => { + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + skipIfBalance: false, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: TRANSACTION_TOKEN_MOCK.balanceHuman, + sourceAmountRaw: TRANSACTION_TOKEN_MOCK.balanceRaw, + sourceBalanceRaw: TRANSACTION_TOKEN_MOCK.balanceRaw, + sourceChainId: TRANSACTION_TOKEN_MOCK.chainId, + sourceTokenAddress: TRANSACTION_TOKEN_MOCK.address, + targetTokenAddress: DESTINATION_TOKEN_MOCK.address, + }, + ]); + }); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index f98cd422c37..0169872fbaa 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -1,7 +1,7 @@ import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { getTokenFiatRate } from './token'; +import { getTokenFiatRate, isSameToken } from './token'; import { getTransaction } from './transaction'; import type { TransactionPayControllerMessenger, @@ -35,12 +35,25 @@ export function updateSourceAmounts( return; } - const { isMaxAmount, paymentToken, tokens } = transactionData; + const { isMaxAmount, isPostQuote, paymentToken, tokens } = transactionData; if (!tokens.length || !paymentToken) { return; } + // For post-quote flows, source amounts are calculated differently + // The source is the transaction's required token, not the selected token + if (isPostQuote) { + const sourceAmounts = calculatePostQuoteSourceAmounts( + tokens, + paymentToken, + isMaxAmount ?? false, + ); + log('Updated post-quote source amounts', { transactionId, sourceAmounts }); + transactionData.sourceAmounts = sourceAmounts; + return; + } + const sourceAmounts = tokens .map((singleToken) => calculateSourceAmount( @@ -58,6 +71,51 @@ export function updateSourceAmounts( transactionData.sourceAmounts = sourceAmounts; } +/** + * Calculate source amounts for post-quote flows. + * In this flow, the required tokens ARE the source tokens, + * and the payment token is the target (destination). + * + * @param tokens - Required tokens from the transaction. + * @param paymentToken - Selected payment/destination token. + * @param isMaxAmount - Whether the transaction is a maximum amount transaction. + * @returns Array of source amounts. + */ +function calculatePostQuoteSourceAmounts( + tokens: TransactionPayRequiredToken[], + paymentToken: TransactionPaymentToken, + isMaxAmount: boolean, +): TransactionPaySourceAmount[] { + return tokens + .filter((token) => { + if (token.skipIfBalance) { + return false; + } + + // Skip zero amounts (unless max amount, where we use balance) + if (token.amountRaw === '0' && !isMaxAmount) { + log('Skipping token as zero amount', { tokenAddress: token.address }); + return false; + } + + // Skip same token on same chain + if (isSameToken(token, paymentToken)) { + log('Skipping token as same as destination token'); + return false; + } + + return true; + }) + .map((token) => ({ + sourceAmountHuman: isMaxAmount ? token.balanceHuman : token.amountHuman, + sourceAmountRaw: isMaxAmount ? token.balanceRaw : token.amountRaw, + sourceBalanceRaw: token.balanceRaw, + sourceChainId: token.chainId, + sourceTokenAddress: token.address, + targetTokenAddress: paymentToken.address, + })); +} + /** * Calculate the required source amount for a payment token to cover a target token. * @@ -95,14 +153,9 @@ function calculateSourceAmount( } const strategy = getStrategyType(transactionId, messenger); - - const isSameTokenSelected = - token.address.toLowerCase() === paymentToken.address.toLowerCase() && - token.chainId === paymentToken.chainId; - const isAlwaysRequired = isQuoteAlwaysRequired(token, strategy); - if (isSameTokenSelected && !isAlwaysRequired) { + if (isSameToken(token, paymentToken) && !isAlwaysRequired) { log('Skipping token as same as payment token'); return undefined; } diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts index ffdaa5fc353..6b5d508f3bf 100644 --- a/packages/transaction-pay-controller/src/utils/token.test.ts +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -9,6 +9,7 @@ import { getTokenFiatRate, getAllTokenBalances, getNativeToken, + isSameToken, } from './token'; import { CHAIN_ID_POLYGON, @@ -471,4 +472,47 @@ describe('Token Utils', () => { ]); }); }); + + describe('isSameToken', () => { + it('returns true for same address and chain', () => { + const token1 = { address: TOKEN_ADDRESS_MOCK, chainId: CHAIN_ID_MOCK }; + const token2 = { address: TOKEN_ADDRESS_MOCK, chainId: CHAIN_ID_MOCK }; + + expect(isSameToken(token1, token2)).toBe(true); + }); + + it('returns true for same address with different case', () => { + const token1 = { + address: TOKEN_ADDRESS_MOCK.toLowerCase() as Hex, + chainId: CHAIN_ID_MOCK, + }; + const token2 = { + address: TOKEN_ADDRESS_MOCK.toUpperCase() as Hex, + chainId: CHAIN_ID_MOCK, + }; + + expect(isSameToken(token1, token2)).toBe(true); + }); + + it('returns false for different addresses', () => { + const token1 = { address: TOKEN_ADDRESS_MOCK, chainId: CHAIN_ID_MOCK }; + const token2 = { address: TOKEN_ADDRESS_2_MOCK, chainId: CHAIN_ID_MOCK }; + + expect(isSameToken(token1, token2)).toBe(false); + }); + + it('returns false for different chains', () => { + const token1 = { address: TOKEN_ADDRESS_MOCK, chainId: CHAIN_ID_MOCK }; + const token2 = { address: TOKEN_ADDRESS_MOCK, chainId: '0x89' as Hex }; + + expect(isSameToken(token1, token2)).toBe(false); + }); + + it('returns false for different address and chain', () => { + const token1 = { address: TOKEN_ADDRESS_MOCK, chainId: CHAIN_ID_MOCK }; + const token2 = { address: TOKEN_ADDRESS_2_MOCK, chainId: '0x89' as Hex }; + + expect(isSameToken(token1, token2)).toBe(false); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts index b05f0a5f32c..fdca1027f25 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -3,19 +3,29 @@ import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { uniq } from 'lodash'; -import { - ARBITRUM_USDC_ADDRESS, - CHAIN_ID_ARBITRUM, - CHAIN_ID_POLYGON, - NATIVE_TOKEN_ADDRESS, - POLYGON_USDCE_ADDRESS, -} from '../constants'; +import { NATIVE_TOKEN_ADDRESS, STABLECOINS } from '../constants'; import type { FiatRates, TransactionPayControllerMessenger } from '../types'; -const STABLECOINS: Record = { - [CHAIN_ID_ARBITRUM]: [ARBITRUM_USDC_ADDRESS.toLowerCase() as Hex], - [CHAIN_ID_POLYGON]: [POLYGON_USDCE_ADDRESS.toLowerCase() as Hex], -}; +/** + * Check if two tokens are the same (same address and chain). + * + * @param token1 - First token identifier. + * @param token1.address - Token address. + * @param token1.chainId - Token chain ID. + * @param token2 - Second token identifier. + * @param token2.address - Token address. + * @param token2.chainId - Token chain ID. + * @returns True if tokens are the same, false otherwise. + */ +export function isSameToken( + token1: { address: Hex; chainId: Hex }, + token2: { address: Hex; chainId: Hex }, +): boolean { + return ( + token1.address.toLowerCase() === token2.address.toLowerCase() && + token1.chainId === token2.chainId + ); +} /** * Get the token balance for a specific account and token. diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index 59c1beb6a59..baaa17926d4 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -54,9 +54,7 @@ const QUOTE_1_MOCK: TransactionPayQuote = { }, strategy: TransactionPayStrategy.Test, targetAmount: { - human: '9.99', fiat: '9.99', - raw: '999000000000000', usd: '10.10', }, }; @@ -112,9 +110,7 @@ const QUOTE_2_MOCK: TransactionPayQuote = { }, strategy: TransactionPayStrategy.Test, targetAmount: { - human: '15.15', fiat: '15.15', - raw: '1515000000000000', usd: '16.16', }, }; diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index 7588857a9cb..b5bdfb37279 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -58,7 +58,7 @@ export function calculateTotals({ : transactionNetworkFee; const sourceAmount = sumAmounts(quotes.map((quote) => quote.sourceAmount)); - const targetAmount = sumAmounts(quotes.map((quote) => quote.targetAmount)); + const targetAmount = sumFiat(quotes.map((quote) => quote.targetAmount)); const quoteTokens = tokens.filter( (singleToken) => !singleToken.skipIfBalance, diff --git a/packages/user-operation-controller/CHANGELOG.md b/packages/user-operation-controller/CHANGELOG.md index b1c5a58e0ed..4f759246223 100644 --- a/packages/user-operation-controller/CHANGELOG.md +++ b/packages/user-operation-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.16.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)) +- Bump `@metamask/transaction-controller` from `^62.9.2` to `^62.17.0` ([#7737](https://github.com/MetaMask/core/pull/7737), [#7760](https://github.com/MetaMask/core/pull/7760), [#7775](https://github.com/MetaMask/core/pull/7775), [#7802](https://github.com/MetaMask/core/pull/7802), [#7832](https://github.com/MetaMask/core/pull/7832), [#7854](https://github.com/MetaMask/core/pull/7854), [#7872](https://github.com/MetaMask/core/pull/7872)), ([#7897](https://github.com/MetaMask/core/pull/7897)) - Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713)) ## [41.0.2] diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index d7e74a79199..9bf80e3f789 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -60,7 +60,7 @@ "@metamask/polling-controller": "^16.0.2", "@metamask/rpc-errors": "^7.0.2", "@metamask/superstruct": "^3.1.0", - "@metamask/transaction-controller": "^62.16.0", + "@metamask/transaction-controller": "^62.17.0", "@metamask/utils": "^11.9.0", "bn.js": "^5.2.1", "immer": "^9.0.6", @@ -71,11 +71,11 @@ "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-block-tracker": "^15.0.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/user-operation-controller/src/UserOperationController.test.ts b/packages/user-operation-controller/src/UserOperationController.test.ts index e176190e91d..06b977354bd 100644 --- a/packages/user-operation-controller/src/UserOperationController.test.ts +++ b/packages/user-operation-controller/src/UserOperationController.test.ts @@ -1457,7 +1457,7 @@ describe('UserOperationController', () => { controller.metadata, 'includeInDebugSnapshot', ), - ).toMatchInlineSnapshot(`Object {}`); + ).toMatchInlineSnapshot(`{}`); }); it('includes expected state in state logs', () => { @@ -1470,8 +1470,8 @@ describe('UserOperationController', () => { 'includeInStateLogs', ), ).toMatchInlineSnapshot(` - Object { - "userOperations": Object {}, + { + "userOperations": {}, } `); }); @@ -1486,8 +1486,8 @@ describe('UserOperationController', () => { 'persist', ), ).toMatchInlineSnapshot(` - Object { - "userOperations": Object {}, + { + "userOperations": {}, } `); }); @@ -1502,8 +1502,8 @@ describe('UserOperationController', () => { 'usedInUi', ), ).toMatchInlineSnapshot(` - Object { - "userOperations": Object {}, + { + "userOperations": {}, } `); }); diff --git a/scripts/create-package/package-template/package.json b/scripts/create-package/package-template/package.json index 47b21318bf9..2d08863b9fe 100644 --- a/scripts/create-package/package-template/package.json +++ b/scripts/create-package/package-template/package.json @@ -50,11 +50,11 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.4.1", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.4", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/teams.json b/teams.json index 9e0811957fe..bbb65f312e7 100644 --- a/teams.json +++ b/teams.json @@ -24,6 +24,7 @@ "metamask/eip-7702-internal-rpc-middleware": "team-delegation", "metamask/earn-controller": "team-earn", "metamask/notification-services-controller": "team-assets", + "metamask/compliance-controller": "team-perps", "metamask/perps-controller": "team-perps", "metamask/phishing-controller": "team-product-safety", "metamask/bridge-controller": "team-swaps-and-bridge", @@ -35,6 +36,7 @@ "metamask/multichain-api-middleware": "team-wallet-integrations", "metamask/selected-network-controller": "team-wallet-integrations", "metamask/eip-5792-middleware": "team-wallet-integrations", + "metamask/client-controller": "team-core-platform,team-extension-platform,team-mobile-platform", "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", diff --git a/tests/helpers.ts b/tests/helpers.ts index 3e5aea77c01..b4c60c01473 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -11,22 +11,19 @@ import { getKnownPropertyNames } from '@metamask/utils'; * between each step, this function ensures that both timers and promises are comprehensively processed. * * @param options - The options object. - * @param options.clock - The Sinon fake timer instance used to manipulate time in tests. * @param options.duration - The total amount of time (in milliseconds) to advance the timer by. * @param options.stepSize - The incremental step size (in milliseconds) by which the timer is advanced in each iteration. Default is 1/4 of the duration. */ -export async function advanceTime({ - clock, +export async function jestAdvanceTime({ duration, stepSize = duration / 4, }: { - clock: sinon.SinonFakeTimers; duration: number; stepSize?: number; }): Promise { let value = duration; do { - await clock.tickAsync(stepSize); + await jest.advanceTimersByTimeAsync(stepSize); value -= stepSize; } while (value > 0); } diff --git a/tests/setup.ts b/tests/setup.ts index 8e9bb0b63d9..b9508687d77 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,3 +1,14 @@ -// This import is used for side effects only. -// eslint-disable-next-line import-x/no-unassigned-import +// Node.js v24 native fetch uses undici, which nock cannot intercept. +// Clear it so isomorphic-fetch sets up node-fetch (http-based, nock-compatible). +// @ts-expect-error Intentionally clearing native globals +delete globalThis.fetch; +// @ts-expect-error - Clear Response to ensure isomorphic-fetch sets up node-fetch version +delete globalThis.Headers; +// @ts-expect-error - Clear Response to ensure isomorphic-fetch sets up node-fetch version +delete globalThis.Request; +// @ts-expect-error - Clear Response to ensure isomorphic-fetch sets up node-fetch version +delete globalThis.Response; +// We need to import this *after* we delete `fetch` etc. above. +// Additionally, this import is used for side effects only. +// eslint-disable-next-line import-x/first, import-x/no-unassigned-import import 'isomorphic-fetch'; diff --git a/tsconfig.base.json b/tsconfig.base.json index a1fe2b64848..ab31b1bc9b0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,6 +5,7 @@ "compilerOptions": { "composite": true, "esModuleInterop": true, + "isolatedModules": true, "lib": ["ES2020", "DOM"], "module": "Node16", "moduleResolution": "Node16", diff --git a/tsconfig.build.json b/tsconfig.build.json index 8fa8236e1b1..ab53d9e4c52 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -58,6 +58,9 @@ { "path": "./packages/claims-controller/tsconfig.build.json" }, + { + "path": "./packages/client-controller/tsconfig.build.json" + }, { "path": "./packages/composable-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 43acf48fbd2..509c82b30fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,9 @@ { "path": "./packages/claims-controller" }, + { + "path": "./packages/client-controller" + }, { "path": "./packages/composable-controller" }, diff --git a/yarn.lock b/yarn.lock index 7d2d7125a39..22e77b9cd50 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,230 +12,149 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.2.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10/f3451525379c68a73eb0a1e65247fbf28c0cccd126d93af21c75fceff77773d43c0d4a2d51978fb131aff25b5f2cb41a9fe48cc296e61ae65e679c4f6918b0ab - languageName: node - linkType: hard - -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.0, @babel/code-frame@npm:^7.26.2": - version: 7.26.2 - resolution: "@babel/code-frame@npm:7.26.2" +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/code-frame@npm:7.29.0" dependencies: - "@babel/helper-validator-identifier": "npm:^7.25.9" + "@babel/helper-validator-identifier": "npm:^7.28.5" js-tokens: "npm:^4.0.0" - picocolors: "npm:^1.0.0" - checksum: 10/db2c2122af79d31ca916755331bb4bac96feb2b334cdaca5097a6b467fdd41963b89b14b6836a14f083de7ff887fc78fa1b3c10b14e743d33e12dbfe5ee3d223 + picocolors: "npm:^1.1.1" + checksum: 10/199e15ff89007dd30675655eec52481cb245c9fdf4f81e4dc1f866603b0217b57aff25f5ffa0a95bbc8e31eb861695330cd7869ad52cc211aa63016320ef72c5 languageName: node linkType: hard -"@babel/compat-data@npm:^7.25.9": - version: 7.26.3 - resolution: "@babel/compat-data@npm:7.26.3" - checksum: 10/0bf4e491680722aa0eac26f770f2fae059f92e2ac083900b241c90a2c10f0fc80e448b1feccc2b332687fab4c3e33e9f83dee9ef56badca1fb9f3f71266d9ebf +"@babel/compat-data@npm:^7.28.6": + version: 7.29.0 + resolution: "@babel/compat-data@npm:7.29.0" + checksum: 10/7f21beedb930ed8fbf7eabafc60e6e6521c1d905646bf1317a61b2163339157fe797efeb85962bf55136e166b01fd1a6b526a15974b92a8b877d564dcb6c9580 languageName: node linkType: hard -"@babel/core@npm:^7.1.0, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.5, @babel/core@npm:^7.7.2, @babel/core@npm:^7.8.0": - version: 7.26.0 - resolution: "@babel/core@npm:7.26.0" - dependencies: - "@ampproject/remapping": "npm:^2.2.0" - "@babel/code-frame": "npm:^7.26.0" - "@babel/generator": "npm:^7.26.0" - "@babel/helper-compilation-targets": "npm:^7.25.9" - "@babel/helper-module-transforms": "npm:^7.26.0" - "@babel/helpers": "npm:^7.26.0" - "@babel/parser": "npm:^7.26.0" - "@babel/template": "npm:^7.25.9" - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.26.0" +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9": + version: 7.29.0 + resolution: "@babel/core@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-compilation-targets": "npm:^7.28.6" + "@babel/helper-module-transforms": "npm:^7.28.6" + "@babel/helpers": "npm:^7.28.6" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/traverse": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/remapping": "npm:^2.3.5" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10/65767bfdb1f02e80d3af4f138066670ef8fdd12293de85ef151758a901c191c797e86d2e99b11c4cdfca33c72385ecaf38bbd7fa692791ec44c77763496b9b93 + checksum: 10/25f4e91688cdfbaf1365831f4f245b436cdaabe63d59389b75752013b8d61819ee4257101b52fc328b0546159fd7d0e74457ed7cf12c365fea54be4fb0a40229 languageName: node linkType: hard -"@babel/generator@npm:^7.26.0, @babel/generator@npm:^7.26.3, @babel/generator@npm:^7.7.2": - version: 7.26.3 - resolution: "@babel/generator@npm:7.26.3" +"@babel/generator@npm:^7.29.0, @babel/generator@npm:^7.7.2": + version: 7.29.1 + resolution: "@babel/generator@npm:7.29.1" dependencies: - "@babel/parser": "npm:^7.26.3" - "@babel/types": "npm:^7.26.3" - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" + "@babel/parser": "npm:^7.29.0" + "@babel/types": "npm:^7.29.0" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" jsesc: "npm:^3.0.2" - checksum: 10/c1d8710cc1c52af9d8d67f7d8ea775578aa500887b327d2a81e27494764a6ef99e438dd7e14cf7cd3153656492ee27a8362980dc438087c0ca39d4e75532c638 - languageName: node - linkType: hard - -"@babel/helper-annotate-as-pure@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-annotate-as-pure@npm:7.24.7" - dependencies: - "@babel/types": "npm:^7.24.7" - checksum: 10/a9017bfc1c4e9f2225b967fbf818004703de7cf29686468b54002ffe8d6b56e0808afa20d636819fcf3a34b89ba72f52c11bdf1d69f303928ee10d92752cad95 + checksum: 10/61fe4ddd6e817aa312a14963ccdbb5c9a8c57e8b97b98d19a8a99ccab2215fda1a5f52bc8dd8d2e3c064497ddeb3ab8ceb55c76fa0f58f8169c34679d2256fe0 languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-compilation-targets@npm:7.25.9" +"@babel/helper-compilation-targets@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-compilation-targets@npm:7.28.6" dependencies: - "@babel/compat-data": "npm:^7.25.9" - "@babel/helper-validator-option": "npm:^7.25.9" + "@babel/compat-data": "npm:^7.28.6" + "@babel/helper-validator-option": "npm:^7.27.1" browserslist: "npm:^4.24.0" lru-cache: "npm:^5.1.1" semver: "npm:^6.3.1" - checksum: 10/8053fbfc21e8297ab55c8e7f9f119e4809fa7e505268691e1bedc2cf5e7a5a7de8c60ad13da2515378621b7601c42e101d2d679904da395fa3806a1edef6b92e - languageName: node - linkType: hard - -"@babel/helper-create-class-features-plugin@npm:^7.25.0": - version: 7.25.4 - resolution: "@babel/helper-create-class-features-plugin@npm:7.25.4" - dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.24.7" - "@babel/helper-member-expression-to-functions": "npm:^7.24.8" - "@babel/helper-optimise-call-expression": "npm:^7.24.7" - "@babel/helper-replace-supers": "npm:^7.25.0" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.24.7" - "@babel/traverse": "npm:^7.25.4" - semver: "npm:^6.3.1" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/47218da9fd964af30d41f0635d9e33eed7518e03aa8f10c3eb8a563bb2c14f52be3e3199db5912ae0e26058c23bb511c811e565c55ecec09427b04b867ed13c2 - languageName: node - linkType: hard - -"@babel/helper-member-expression-to-functions@npm:^7.24.8": - version: 7.24.8 - resolution: "@babel/helper-member-expression-to-functions@npm:7.24.8" - dependencies: - "@babel/traverse": "npm:^7.24.8" - "@babel/types": "npm:^7.24.8" - checksum: 10/ac878761cfd0a46c081cda0da75cc186f922cf16e8ecdd0c4fb6dca4330d9fe4871b41a9976224cf9669c9e7fe0421b5c27349f2e99c125fa0be871b327fa770 - languageName: node - linkType: hard - -"@babel/helper-module-imports@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-module-imports@npm:7.25.9" - dependencies: - "@babel/traverse": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10/e090be5dee94dda6cd769972231b21ddfae988acd76b703a480ac0c96f3334557d70a965bf41245d6ee43891e7571a8b400ccf2b2be5803351375d0f4e5bcf08 + checksum: 10/f512a5aeee4dfc6ea8807f521d085fdca8d66a7d068a6dd5e5b37da10a6081d648c0bbf66791a081e4e8e6556758da44831b331540965dfbf4f5275f3d0a8788 languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.24.8, @babel/helper-module-transforms@npm:^7.26.0": - version: 7.26.0 - resolution: "@babel/helper-module-transforms@npm:7.26.0" - dependencies: - "@babel/helper-module-imports": "npm:^7.25.9" - "@babel/helper-validator-identifier": "npm:^7.25.9" - "@babel/traverse": "npm:^7.25.9" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/9841d2a62f61ad52b66a72d08264f23052d533afc4ce07aec2a6202adac0bfe43014c312f94feacb3291f4c5aafe681955610041ece2c276271adce3f570f2f5 +"@babel/helper-globals@npm:^7.28.0": + version: 7.28.0 + resolution: "@babel/helper-globals@npm:7.28.0" + checksum: 10/91445f7edfde9b65dcac47f4f858f68dc1661bf73332060ab67ad7cc7b313421099a2bfc4bda30c3db3842cfa1e86fffbb0d7b2c5205a177d91b22c8d7d9cb47 languageName: node linkType: hard -"@babel/helper-optimise-call-expression@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-optimise-call-expression@npm:7.24.7" +"@babel/helper-module-imports@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-imports@npm:7.28.6" dependencies: - "@babel/types": "npm:^7.24.7" - checksum: 10/da7a7f2d1bb1be4cffd5fa820bd605bc075c7dd014e0458f608bb6f34f450fe9412c8cea93e788227ab396e0e02c162d7b1db3fbcb755a6360e354c485d61df0 - languageName: node - linkType: hard - -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.24.7, @babel/helper-plugin-utils@npm:^7.24.8, @babel/helper-plugin-utils@npm:^7.8.0": - version: 7.24.8 - resolution: "@babel/helper-plugin-utils@npm:7.24.8" - checksum: 10/adbc9fc1142800a35a5eb0793296924ee8057fe35c61657774208670468a9fbfbb216f2d0bc46c680c5fefa785e5ff917cc1674b10bd75cdf9a6aa3444780630 + "@babel/traverse": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10/64b1380d74425566a3c288074d7ce4dea56d775d2d3325a3d4a6df1dca702916c1d268133b6f385de9ba5b822b3c6e2af5d3b11ac88e5453d5698d77264f0ec0 languageName: node linkType: hard -"@babel/helper-replace-supers@npm:^7.25.0": - version: 7.25.0 - resolution: "@babel/helper-replace-supers@npm:7.25.0" +"@babel/helper-module-transforms@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helper-module-transforms@npm:7.28.6" dependencies: - "@babel/helper-member-expression-to-functions": "npm:^7.24.8" - "@babel/helper-optimise-call-expression": "npm:^7.24.7" - "@babel/traverse": "npm:^7.25.0" + "@babel/helper-module-imports": "npm:^7.28.6" + "@babel/helper-validator-identifier": "npm:^7.28.5" + "@babel/traverse": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10/97c6c17780cb9692132f7243f5a21fb6420104cb8ff8752dc03cfc9a1912a243994c0290c77ff096637ab6f2a7363b63811cfc68c2bad44e6b39460ac2f6a63f + checksum: 10/2e421c7db743249819ee51e83054952709dc2e197c7d5d415b4bdddc718580195704bfcdf38544b3f674efc2eccd4d29a65d38678fc827ed3934a7690984cd8b languageName: node linkType: hard -"@babel/helper-simple-access@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-simple-access@npm:7.24.7" - dependencies: - "@babel/traverse": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10/5083e190186028e48fc358a192e4b93ab320bd016103caffcfda81302a13300ccce46c9cd255ae520c25d2a6a9b47671f93e5fe5678954a2329dc0a685465c49 - languageName: node - linkType: hard - -"@babel/helper-skip-transparent-expression-wrappers@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/helper-skip-transparent-expression-wrappers@npm:7.24.7" - dependencies: - "@babel/traverse": "npm:^7.24.7" - "@babel/types": "npm:^7.24.7" - checksum: 10/784a6fdd251a9a7e42ccd04aca087ecdab83eddc60fda76a2950e00eb239cc937d3c914266f0cc476298b52ac3f44ffd04c358e808bd17552a7e008d75494a77 +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.24.7, @babel/helper-plugin-utils@npm:^7.24.8, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.8.0": + version: 7.28.6 + resolution: "@babel/helper-plugin-utils@npm:7.28.6" + checksum: 10/21c853bbc13dbdddf03309c9a0477270124ad48989e1ad6524b83e83a77524b333f92edd2caae645c5a7ecf264ec6d04a9ebe15aeb54c7f33c037b71ec521e4a languageName: node linkType: hard -"@babel/helper-string-parser@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-string-parser@npm:7.25.9" - checksum: 10/c28656c52bd48e8c1d9f3e8e68ecafd09d949c57755b0d353739eb4eae7ba4f7e67e92e4036f1cd43378cc1397a2c943ed7bcaf5949b04ab48607def0258b775 +"@babel/helper-string-parser@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-string-parser@npm:7.27.1" + checksum: 10/0ae29cc2005084abdae2966afdb86ed14d41c9c37db02c3693d5022fba9f5d59b011d039380b8e537c34daf117c549f52b452398f576e908fb9db3c7abbb3a00 languageName: node linkType: hard -"@babel/helper-validator-identifier@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-validator-identifier@npm:7.25.9" - checksum: 10/3f9b649be0c2fd457fa1957b694b4e69532a668866b8a0d81eabfa34ba16dbf3107b39e0e7144c55c3c652bf773ec816af8df4a61273a2bb4eb3145ca9cf478e +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10/8e5d9b0133702cfacc7f368bf792f0f8ac0483794877c6dca5fcb73810ee138e27527701826fb58a40a004f3a5ec0a2f3c3dd5e326d262530b119918f3132ba7 languageName: node linkType: hard -"@babel/helper-validator-option@npm:^7.24.7, @babel/helper-validator-option@npm:^7.25.9": - version: 7.25.9 - resolution: "@babel/helper-validator-option@npm:7.25.9" - checksum: 10/9491b2755948ebbdd68f87da907283698e663b5af2d2b1b02a2765761974b1120d5d8d49e9175b167f16f72748ffceec8c9cf62acfbee73f4904507b246e2b3d +"@babel/helper-validator-option@npm:^7.27.1": + version: 7.27.1 + resolution: "@babel/helper-validator-option@npm:7.27.1" + checksum: 10/db73e6a308092531c629ee5de7f0d04390835b21a263be2644276cb27da2384b64676cab9f22cd8d8dbd854c92b1d7d56fc8517cf0070c35d1c14a8c828b0903 languageName: node linkType: hard -"@babel/helpers@npm:^7.26.0": - version: 7.26.0 - resolution: "@babel/helpers@npm:7.26.0" +"@babel/helpers@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/helpers@npm:7.28.6" dependencies: - "@babel/template": "npm:^7.25.9" - "@babel/types": "npm:^7.26.0" - checksum: 10/fd4757f65d10b64cfdbf4b3adb7ea6ffff9497c53e0786452f495d1f7794da7e0898261b4db65e1c62bbb9a360d7d78a1085635c23dfc3af2ab6dcba06585f86 + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10/213485cdfffc4deb81fc1bf2cefed61bc825049322590ef69690e223faa300a2a4d1e7d806c723bb1f1f538226b9b1b6c356ca94eb47fa7c6d9e9f251ee425e6 languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.0, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.25.9, @babel/parser@npm:^7.26.0, @babel/parser@npm:^7.26.3": - version: 7.26.3 - resolution: "@babel/parser@npm:7.26.3" +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.0, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/parser@npm:7.29.0" dependencies: - "@babel/types": "npm:^7.26.3" + "@babel/types": "npm:^7.29.0" bin: parser: ./bin/babel-parser.js - checksum: 10/e7e3814b2dc9ee3ed605d38223471fa7d3a84cbe9474d2b5fa7ac57dc1ddf75577b1fd3a93bf7db8f41f28869bda795cddd80223f980be23623b6434bf4c88a8 + checksum: 10/b1576dca41074997a33ee740d87b330ae2e647f4b7da9e8d2abd3772b18385d303b0cee962b9b88425e0f30d58358dbb8d63792c1a2d005c823d335f6a029747 languageName: node linkType: hard @@ -316,14 +235,14 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.24.7": - version: 7.24.7 - resolution: "@babel/plugin-syntax-jsx@npm:7.24.7" +"@babel/plugin-syntax-jsx@npm:^7.7.2": + version: 7.28.6 + resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.7" + "@babel/helper-plugin-utils": "npm:^7.28.6" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10/a93516ae5b34868ab892a95315027d4e5e38e8bd1cfca6158f2974b0901cbb32bbe64ea10ad5b25f919ddc40c6d8113c4823372909c9c9922170c12b0b1acecb + checksum: 10/572e38f5c1bb4b8124300e7e3dd13e82ae84a21f90d3f0786c98cd05e63c78ca1f32d1cfe462dfbaf5e7d5102fa7cd8fd741dfe4f3afc2e01a3b2877dcc8c866 languageName: node linkType: hard @@ -415,7 +334,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.24.7, @babel/plugin-syntax-typescript@npm:^7.7.2": +"@babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.25.4 resolution: "@babel/plugin-syntax-typescript@npm:7.25.4" dependencies: @@ -426,49 +345,6 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-modules-commonjs@npm:^7.23.3, @babel/plugin-transform-modules-commonjs@npm:^7.24.7": - version: 7.24.8 - resolution: "@babel/plugin-transform-modules-commonjs@npm:7.24.8" - dependencies: - "@babel/helper-module-transforms": "npm:^7.24.8" - "@babel/helper-plugin-utils": "npm:^7.24.8" - "@babel/helper-simple-access": "npm:^7.24.7" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/18e5d229767c7b5b6ff0cbf1a8d2d555965b90201839d0ac2dc043b56857624ea344e59f733f028142a8c1d54923b82e2a0185694ef36f988d797bfbaf59819c - languageName: node - linkType: hard - -"@babel/plugin-transform-typescript@npm:^7.24.7": - version: 7.25.2 - resolution: "@babel/plugin-transform-typescript@npm:7.25.2" - dependencies: - "@babel/helper-annotate-as-pure": "npm:^7.24.7" - "@babel/helper-create-class-features-plugin": "npm:^7.25.0" - "@babel/helper-plugin-utils": "npm:^7.24.8" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.24.7" - "@babel/plugin-syntax-typescript": "npm:^7.24.7" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/50e017ffd131c08661daa22b6c759999bb7a6cdfbf683291ee4bcbea4ae839440b553d2f8896bcf049aca1d267b39f3b09e8336059e919e83149b5ad859671f6 - languageName: node - linkType: hard - -"@babel/preset-typescript@npm:^7.23.3": - version: 7.24.7 - resolution: "@babel/preset-typescript@npm:7.24.7" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.7" - "@babel/helper-validator-option": "npm:^7.24.7" - "@babel/plugin-syntax-jsx": "npm:^7.24.7" - "@babel/plugin-transform-modules-commonjs": "npm:^7.24.7" - "@babel/plugin-transform-typescript": "npm:^7.24.7" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/995e9783f8e474581e7533d6b10ec1fbea69528cc939ad8582b5937e13548e5215d25a8e2c845e7b351fdaa13139896b5e42ab3bde83918ea4e41773f10861ac - languageName: node - linkType: hard - "@babel/runtime@npm:^7.23.9": version: 7.25.4 resolution: "@babel/runtime@npm:7.25.4" @@ -478,39 +354,39 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.25.9, @babel/template@npm:^7.3.3": - version: 7.25.9 - resolution: "@babel/template@npm:7.25.9" +"@babel/template@npm:^7.28.6, @babel/template@npm:^7.3.3": + version: 7.28.6 + resolution: "@babel/template@npm:7.28.6" dependencies: - "@babel/code-frame": "npm:^7.25.9" - "@babel/parser": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" - checksum: 10/e861180881507210150c1335ad94aff80fd9e9be6202e1efa752059c93224e2d5310186ddcdd4c0f0b0fc658ce48cb47823f15142b5c00c8456dde54f5de80b2 + "@babel/code-frame": "npm:^7.28.6" + "@babel/parser": "npm:^7.28.6" + "@babel/types": "npm:^7.28.6" + checksum: 10/0ad6e32bf1e7e31bf6b52c20d15391f541ddd645cbd488a77fe537a15b280ee91acd3a777062c52e03eedbc2e1f41548791f6a3697c02476ec5daf49faa38533 languageName: node linkType: hard -"@babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.24.8, @babel/traverse@npm:^7.25.0, @babel/traverse@npm:^7.25.4, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.7.2": - version: 7.26.4 - resolution: "@babel/traverse@npm:7.26.4" - dependencies: - "@babel/code-frame": "npm:^7.26.2" - "@babel/generator": "npm:^7.26.3" - "@babel/parser": "npm:^7.26.3" - "@babel/template": "npm:^7.25.9" - "@babel/types": "npm:^7.26.3" +"@babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": + version: 7.29.0 + resolution: "@babel/traverse@npm:7.29.0" + dependencies: + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" debug: "npm:^4.3.1" - globals: "npm:^11.1.0" - checksum: 10/30c81a80d66fc39842814bc2e847f4705d30f3859156f130d90a0334fe1d53aa81eed877320141a528ecbc36448acc0f14f544a7d410fa319d1c3ab63b50b58f + checksum: 10/3a0d0438f1ba9fed4fbe1706ea598a865f9af655a16ca9517ab57bda526e224569ca1b980b473fb68feea5e08deafbbf2cf9febb941f92f2d2533310c3fc4abc languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.24.7, @babel/types@npm:^7.24.8, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.0, @babel/types@npm:^7.26.3, @babel/types@npm:^7.3.3": - version: 7.26.3 - resolution: "@babel/types@npm:7.26.3" +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.3": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" dependencies: - "@babel/helper-string-parser": "npm:^7.25.9" - "@babel/helper-validator-identifier": "npm:^7.25.9" - checksum: 10/c31d0549630a89abfa11410bf82a318b0c87aa846fbf5f9905e47ba5e2aa44f41cc746442f105d622c519e4dc532d35a8d8080460ff4692f9fc7485fbf3a00eb + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10/bfc2b211210f3894dcd7e6a33b2d1c32c93495dc1e36b547376aa33441abe551ab4bc1640d4154ee2acd8e46d3bbc925c7224caae02fcaf0e6a771e97fccc661 languageName: node linkType: hard @@ -762,21 +638,21 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.4.1, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": - version: 4.9.0 - resolution: "@eslint-community/eslint-utils@npm:4.9.0" +"@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.4.1, @eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1": + version: 4.9.1 + resolution: "@eslint-community/eslint-utils@npm:4.9.1" dependencies: eslint-visitor-keys: "npm:^3.4.3" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10/89b1eb3137e14c379865e60573f524fcc0ee5c4b0c7cd21090673e75e5a720f14b92f05ab2d02704c2314b67e67b6f96f3bb209ded6b890ced7b667aa4bf1fa2 + checksum: 10/863b5467868551c9ae34d03eefe634633d08f623fc7b19d860f8f26eb6f303c1a5934253124163bee96181e45ed22bf27473dccc295937c3078493a4a8c9eddd languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.11.0, @eslint-community/regexpp@npm:^4.12.1": - version: 4.12.1 - resolution: "@eslint-community/regexpp@npm:4.12.1" - checksum: 10/c08f1dd7dd18fbb60bdd0d85820656d1374dd898af9be7f82cb00451313402a22d5e30569c150315b4385907cdbca78c22389b2a72ab78883b3173be317620cc +"@eslint-community/regexpp@npm:^4.11.0, @eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2": + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 10/049b280fddf71dd325514e0a520024969431dc3a8b02fa77476e6820e9122f28ab4c9168c11821f91a27982d2453bcd7a66193356ea84e84fb7c8d793be1ba0c languageName: node linkType: hard @@ -1983,57 +1859,57 @@ __metadata: languageName: node linkType: hard -"@istanbuljs/schema@npm:^0.1.2": +"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": version: 0.1.3 resolution: "@istanbuljs/schema@npm:0.1.3" checksum: 10/a9b1e49acdf5efc2f5b2359f2df7f90c5c725f2656f16099e8b2cd3a000619ecca9fc48cf693ba789cf0fd989f6e0df6a22bc05574be4223ecdbb7997d04384b languageName: node linkType: hard -"@jest/console@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/console@npm:27.5.1" +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" - jest-message-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" slash: "npm:^3.0.0" - checksum: 10/f724ff9693b09711fded8b87145c3446091bde87f572e210667c2b8290b5364c776f3a99c7d1fd6d5642f7f9424d5acc312c12e9cc4da2ef0260d34547869fdd + checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e languageName: node linkType: hard -"@jest/core@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/core@npm:27.5.1" +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/reporters": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" ansi-escapes: "npm:^4.2.1" chalk: "npm:^4.0.0" - emittery: "npm:^0.8.1" + ci-info: "npm:^3.2.0" exit: "npm:^0.1.2" graceful-fs: "npm:^4.2.9" - jest-changed-files: "npm:^27.5.1" - jest-config: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-resolve-dependencies: "npm:^27.5.1" - jest-runner: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - jest-watcher: "npm:^27.5.1" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" micromatch: "npm:^4.0.4" - rimraf: "npm:^3.0.0" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" strip-ansi: "npm:^6.0.0" peerDependencies: @@ -2041,19 +1917,19 @@ __metadata: peerDependenciesMeta: node-notifier: optional: true - checksum: 10/79eb63c3197336c39de6a3341d3f5e7dbca7e20796bd4ee3d725e4ef2832f4d07242898a8af6c9de19ebd700983385a3df16c024b4497f8beb666c8ffe96ccb4 + checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e languageName: node linkType: hard -"@jest/environment@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/environment@npm:27.5.1" +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" dependencies: - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - checksum: 10/74a2a4427f82b096c4f7223c56a27f64487ee4639b017129f31e99ebb2e9a614eb365ec77c3701d6eedc1c8d711ad2dd4b31d6dfad72cbb6d73a4f1fdc4a86cb + jest-mock: "npm:^29.7.0" + checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 languageName: node linkType: hard @@ -2066,66 +1942,76 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/fake-timers@npm:27.5.1" +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 + languageName: node + linkType: hard + +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - "@sinonjs/fake-timers": "npm:^8.0.1" + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" "@types/node": "npm:*" - jest-message-util: "npm:^27.5.1" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - checksum: 10/dd8b736edbc8da77af3ca14ffaa2f331168618db7b879a3a07a4667af11ae4ff840f64a61e3828e217ee94f06d5a9ba30bf19e5103bb74e193b8216ce4c0708d + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 languageName: node linkType: hard -"@jest/globals@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/globals@npm:27.5.1" +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - expect: "npm:^27.5.1" - checksum: 10/f3b06e9b81686d7a5dd7bafb229cba73bdc90d3e16815deebf302d3a402ac29a1e9bafa274d908caefe7083938402619974c89420d247ab8739acd652c11b16d + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 languageName: node linkType: hard -"@jest/reporters@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/reporters@npm:27.5.1" +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" dependencies: "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" "@types/node": "npm:*" chalk: "npm:^4.0.0" collect-v8-coverage: "npm:^1.0.0" exit: "npm:^0.1.2" - glob: "npm:^7.1.2" + glob: "npm:^7.1.3" graceful-fs: "npm:^4.2.9" istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^5.1.0" + istanbul-lib-instrument: "npm:^6.0.0" istanbul-lib-report: "npm:^3.0.0" istanbul-lib-source-maps: "npm:^4.0.0" istanbul-reports: "npm:^3.1.3" - jest-haste-map: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" slash: "npm:^3.0.0" - source-map: "npm:^0.6.0" string-length: "npm:^4.0.1" - terminal-link: "npm:^2.0.0" - v8-to-istanbul: "npm:^8.1.0" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 10/d49aea4e5b09f9a316f0ff303d11f2db057cadaf370e3e706c024e4ea7f270899cccf7488711def4a930bc23e4f4676f406d1c646f8c6656de4c43dd40652877 + checksum: 10/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc languageName: node linkType: hard @@ -2138,61 +2024,38 @@ __metadata: languageName: node linkType: hard -"@jest/source-map@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/source-map@npm:27.5.1" +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" callsites: "npm:^3.0.0" graceful-fs: "npm:^4.2.9" - source-map: "npm:^0.6.0" - checksum: 10/90b1f4212b7191d594275c9b9aae18319b944e4ed018af74a1661fd9b783983074d00369a111274697b87193aa2b084f0f022a265d070f4a66d39d06d14a0336 + checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb languageName: node linkType: hard -"@jest/test-result@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/test-result@npm:27.5.1" +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/istanbul-lib-coverage": "npm:^2.0.0" collect-v8-coverage: "npm:^1.0.0" - checksum: 10/43cdc31b39857d4d6487345f1bfb9c97157ddfb7ff3e3b843f3999d4a3be5b1e7c1079302459ea627976fa9da7462426dfb26cf231ef2b6eb79bc80b67361c23 - languageName: node - linkType: hard - -"@jest/test-sequencer@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/test-sequencer@npm:27.5.1" - dependencies: - "@jest/test-result": "npm:^27.5.1" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - checksum: 10/74c9c773eb0d8de581e17a7ea1d9173b835c0c91b40665caa42fd68931a2ee7429f9ed59c97a15855d3ad46024a17e7387ad4b900d4540890a7681d4a8a42bdd + checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 languageName: node linkType: hard -"@jest/transform@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/transform@npm:27.5.1" +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.1.0" - "@jest/types": "npm:^27.5.1" - babel-plugin-istanbul: "npm:^6.1.1" - chalk: "npm:^4.0.0" - convert-source-map: "npm:^1.4.0" - fast-json-stable-stringify: "npm:^2.0.0" + "@jest/test-result": "npm:^29.7.0" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - micromatch: "npm:^4.0.4" - pirates: "npm:^4.0.4" + jest-haste-map: "npm:^29.7.0" slash: "npm:^3.0.0" - source-map: "npm:^0.6.1" - write-file-atomic: "npm:^3.0.0" - checksum: 10/9e0bec99971d28fc205e5e282be384a0269760b8452aa94e3d400465819b6c790c862ec5597d8c9439f2da97e68c0c4cec071340ff3e4c4414a34e5b2a19074a + checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 languageName: node linkType: hard @@ -2232,19 +2095,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:^27.5.1": - version: 27.5.1 - resolution: "@jest/types@npm:27.5.1" - dependencies: - "@types/istanbul-lib-coverage": "npm:^2.0.0" - "@types/istanbul-reports": "npm:^3.0.0" - "@types/node": "npm:*" - "@types/yargs": "npm:^16.0.0" - chalk: "npm:^4.0.0" - checksum: 10/d3ca1655673539c54665f3e9135dc70887feb6b667b956e712c38f42e513ae007d3593b8075aecea8f2db7119f911773010f17f93be070b1725fbc6225539b6e - languageName: node - linkType: hard - "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -2259,14 +2109,23 @@ __metadata: languageName: node linkType: hard -"@jridgewell/gen-mapping@npm:^0.3.5": - version: 0.3.5 - resolution: "@jridgewell/gen-mapping@npm:0.3.5" +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10/902f8261dcf450b4af7b93f9656918e02eec80a2169e155000cb2059f90113dd98f3ccf6efc6072cee1dd84cac48cade51da236972d942babc40e4c23da4d62a + languageName: node + linkType: hard + +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" dependencies: - "@jridgewell/set-array": "npm:^1.2.1" - "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/gen-mapping": "npm:^0.3.5" "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10/81587b3c4dd8e6c60252122937cea0c637486311f4ed208b52b62aae2e7a87598f63ec330e6cd0984af494bfb16d3f0d60d3b21d7e5b4aedd2602ff3fe9d32e2 + checksum: 10/c2bb01856e65b506d439455f28aceacf130d6c023d1d4e3b48705e88def3571753e1a887daa04b078b562316c92d26ce36408a60534bceca3f830aec88a339ad languageName: node linkType: hard @@ -2277,27 +2136,20 @@ __metadata: languageName: node linkType: hard -"@jridgewell/set-array@npm:^1.2.1": - version: 1.2.1 - resolution: "@jridgewell/set-array@npm:1.2.1" - checksum: 10/832e513a85a588f8ed4f27d1279420d8547743cc37fcad5a5a76fc74bb895b013dfe614d0eed9cb860048e6546b798f8f2652020b4b2ba0561b05caa8c654b10 - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": version: 1.5.0 resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" checksum: 10/4ed6123217569a1484419ac53f6ea0d9f3b57e5b57ab30d7c267bdb27792a27eb0e4b08e84a2680aa55cc2f2b411ffd6ec3db01c44fdc6dc43aca4b55f8374fd languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": - version: 0.3.25 - resolution: "@jridgewell/trace-mapping@npm:0.3.25" +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: "@jridgewell/resolve-uri": "npm:^3.1.0" "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10/dced32160a44b49d531b80a4a2159dceab6b3ddf0c8e95a0deae4b0e894b172defa63d5ac52a19c2068e1fe7d31ea4ba931fbeec103233ecb4208953967120fc + checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 languageName: node linkType: hard @@ -2423,18 +2275,18 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^4.1.0, @metamask/account-tree-controller@workspace:packages/account-tree-controller": +"@metamask/account-tree-controller@npm:^4.1.1, @metamask/account-tree-controller@workspace:packages/account-tree-controller": version: 0.0.0-use.local resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" dependencies: "@metamask/account-api": "npm:^1.0.0" - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/multichain-account-service": "npm:^6.0.0" + "@metamask/multichain-account-service": "npm:^7.0.0" "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^17.2.0" @@ -2443,13 +2295,13 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" webextension-polyfill: "npm:^0.12.0" @@ -2459,7 +2311,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/accounts-controller@npm:^35.0.2, @metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@npm:^36.0.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: @@ -2481,15 +2333,15 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -2521,11 +2373,11 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2538,12 +2390,13 @@ __metadata: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2558,11 +2411,11 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2578,13 +2431,12 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2598,11 +2450,11 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2623,12 +2475,11 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2644,13 +2495,12 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2663,39 +2513,42 @@ __metadata: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^4.1.0" + "@metamask/account-tree-controller": "npm:^4.1.1" + "@metamask/assets-controllers": "npm:^99.4.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" - "@metamask/core-backend": "npm:^5.1.0" + "@metamask/core-backend": "npm:^5.1.1" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/keyring-internal-api": "npm:^10.0.0" "@metamask/keyring-snap-client": "npm:^8.2.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/network-controller": "npm:^29.0.0" - "@metamask/network-enablement-controller": "npm:^4.1.0" + "@metamask/network-enablement-controller": "npm:^4.1.1" "@metamask/permission-controller": "npm:^12.2.0" "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/preferences-controller": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^99.3.1, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^99.4.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -2708,8 +2561,8 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-api": "npm:^1.0.0" - "@metamask/account-tree-controller": "npm:^4.1.0" - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/account-tree-controller": "npm:^4.1.1" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/approval-controller": "npm:^8.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" @@ -2724,11 +2577,11 @@ __metadata: "@metamask/keyring-snap-client": "npm:^8.2.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^6.0.0" + "@metamask/multichain-account-service": "npm:^7.0.0" "@metamask/network-controller": "npm:^29.0.0" - "@metamask/network-enablement-controller": "npm:^4.1.0" + "@metamask/network-enablement-controller": "npm:^4.1.1" "@metamask/permission-controller": "npm:^12.2.0" - "@metamask/phishing-controller": "npm:^16.2.0" + "@metamask/phishing-controller": "npm:^16.3.0" "@metamask/polling-controller": "npm:^16.0.2" "@metamask/preferences-controller": "npm:^22.1.0" "@metamask/profile-sync-controller": "npm:^27.1.0" @@ -2738,11 +2591,11 @@ __metadata: "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/storage-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/uuid": "npm:^8.3.0" @@ -2751,16 +2604,15 @@ __metadata: bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" multiformats: "npm:^9.9.0" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" single-call-balance-checker-abi: "npm:^1.0.0" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -2845,20 +2697,18 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" - "@types/sinon": "npm:^9.0.10" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^66.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^67.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2867,8 +2717,8 @@ __metadata: "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^35.0.2" - "@metamask/assets-controllers": "npm:^99.3.1" + "@metamask/accounts-controller": "npm:^36.0.0" + "@metamask/assets-controllers": "npm:^99.4.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" @@ -2877,57 +2727,59 @@ __metadata: "@metamask/keyring-api": "npm:^21.5.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-network-controller": "npm:^3.0.2" + "@metamask/multichain-network-controller": "npm:^3.0.3" "@metamask/network-controller": "npm:^29.0.0" "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" languageName: unknown linkType: soft -"@metamask/bridge-status-controller@npm:^66.0.0, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^67.0.0, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^66.0.0" + "@metamask/bridge-controller": "npm:^67.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" "@metamask/network-controller": "npm:^29.0.0" "@metamask/polling-controller": "npm:^16.0.2" + "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -2951,11 +2803,11 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/eslint": "npm:^8.44.7" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2973,12 +2825,12 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -2996,11 +2848,53 @@ __metadata: "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + +"@metamask/client-controller@workspace:packages/client-controller": + version: 0.0.0-use.local + resolution: "@metamask/client-controller@workspace:packages/client-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + +"@metamask/compliance-controller@workspace:packages/compliance-controller": + version: 0.0.0-use.local + resolution: "@metamask/compliance-controller@workspace:packages/compliance-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.18.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3015,13 +2909,12 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.2.2" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3035,11 +2928,11 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3052,7 +2945,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.15.0, @metamask/controller-utils@npm:^11.16.0, @metamask/controller-utils@npm:^11.18.0, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@npm:^11.16.0, @metamask/controller-utils@npm:^11.18.0, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: @@ -3064,7 +2957,7 @@ __metadata: "@spruceid/siwe-parser": "npm:2.1.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" @@ -3072,13 +2965,12 @@ __metadata: deepmerge: "npm:^4.2.2" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" peerDependencies: @@ -3102,11 +2994,11 @@ __metadata: languageName: node linkType: hard -"@metamask/core-backend@npm:^5.1.0, @metamask/core-backend@workspace:packages/core-backend": +"@metamask/core-backend@npm:^5.1.1, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" dependencies: - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/keyring-controller": "npm:^25.1.0" @@ -3115,12 +3007,12 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -3131,9 +3023,6 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/core-monorepo@workspace:." dependencies: - "@babel/core": "npm:^7.23.5" - "@babel/plugin-transform-modules-commonjs": "npm:^7.23.3" - "@babel/preset-typescript": "npm:^7.23.3" "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/create-release-branch": "npm:^4.1.4" @@ -3147,14 +3036,13 @@ __metadata: "@metamask/network-controller": "npm:^29.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/semver": "npm:^7" "@typescript-eslint/eslint-plugin": "npm:^8.48.0" "@typescript-eslint/parser": "npm:^8.48.0" "@yarnpkg/types": "npm:^4.0.0" - babel-jest: "npm:^29.7.0" comment-json: "npm:^4.5.1" depcheck: "npm:^1.4.7" eslint: "npm:^9.39.1" @@ -3168,7 +3056,7 @@ __metadata: eslint-plugin-promise: "npm:^7.1.0" execa: "npm:^5.0.0" isomorphic-fetch: "npm:^3.0.0" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jest-silent-reporter: "npm:^0.5.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" @@ -3181,6 +3069,7 @@ __metadata: tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" typescript-eslint: "npm:^8.48.0" + uuid: "npm:^8.3.2" yargs: "npm:^17.7.2" languageName: unknown linkType: soft @@ -3214,18 +3103,18 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" dependencies: - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3255,7 +3144,7 @@ __metadata: dependencies: "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^4.1.0" + "@metamask/account-tree-controller": "npm:^4.1.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" @@ -3263,14 +3152,14 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/network-controller": "npm:^29.0.0" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3285,16 +3174,16 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" klona: "npm:^2.0.6" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -3311,11 +3200,11 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3333,12 +3222,12 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3356,12 +3245,12 @@ __metadata: "@metamask/network-controller": "npm:^29.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" punycode: "npm:^2.1.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3376,11 +3265,11 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@sentry/core": "npm:^9.22.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3459,13 +3348,13 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/json-rpc-random-id": "npm:^1.0.1" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" json-rpc-random-id: "npm:^1.0.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3527,16 +3416,16 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/pify": "npm:^5.0.2" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" klona: "npm:^2.0.6" pify: "npm:^5.0.0" safe-stable-stringify: "npm:^2.4.3" tsd: "npm:^0.31.2" - typedoc: "npm:^0.24.8" + typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" languageName: unknown linkType: soft @@ -3566,14 +3455,14 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jest-it-up: "npm:^2.0.2" nanoid: "npm:^3.3.8" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" languageName: unknown linkType: soft @@ -3789,17 +3678,17 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/unzipper": "npm:^0.10.10" "@types/yargs": "npm:^17.0.32" "@types/yargs-parser": "npm:^21.0.3" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" minipass: "npm:^7.1.2" nock: "npm:^13.3.1" tar: "npm:^7.4.3" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" unzipper: "npm:^0.12.3" @@ -3826,17 +3715,16 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/jest-when": "npm:^2.7.3" "@types/uuid": "npm:^8.3.0" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jest-when: "npm:^3.4.2" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -3845,7 +3733,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/gator-permissions-controller@npm:^1.1.2, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": +"@metamask/gator-permissions-controller@npm:^2.0.0, @metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller": version: 0.0.0-use.local resolution: "@metamask/gator-permissions-controller@workspace:packages/gator-permissions-controller" dependencies: @@ -3860,14 +3748,14 @@ __metadata: "@metamask/snaps-controllers": "npm:^17.2.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/snaps-utils": "npm:^11.7.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -3885,14 +3773,14 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jest-it-up: "npm:^2.0.2" klona: "npm:^2.0.6" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" languageName: unknown linkType: soft @@ -3906,15 +3794,15 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" extension-port-stream: "npm:^3.0.0" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jest-it-up: "npm:^2.0.2" readable-stream: "npm:^3.6.2" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" webextension-polyfill-ts: "npm:^0.26.0" @@ -3973,17 +3861,16 @@ __metadata: "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" ethereumjs-wallet: "npm:^1.0.1" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" lodash: "npm:^4.17.21" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" ulid: "npm:^2.3.0" @@ -4068,11 +3955,11 @@ __metadata: "@metamask/controller-utils": "npm:^11.18.0" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4090,13 +3977,13 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jsonschema: "npm:^1.4.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4109,13 +3996,12 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4128,13 +4014,13 @@ __metadata: languageName: node linkType: hard -"@metamask/multichain-account-service@npm:^6.0.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": +"@metamask/multichain-account-service@npm:^7.0.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/account-api": "npm:^1.0.0" - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/eth-hd-keyring": "npm:^13.0.0" @@ -4153,14 +4039,14 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4182,7 +4068,7 @@ __metadata: "@metamask/controller-utils": "npm:^11.18.0" "@metamask/eth-json-rpc-filters": "npm:^9.0.0" "@metamask/json-rpc-engine": "npm:^10.2.2" - "@metamask/multichain-transactions-controller": "npm:^7.0.0" + "@metamask/multichain-transactions-controller": "npm:^7.0.1" "@metamask/network-controller": "npm:^29.0.0" "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.2" @@ -4191,22 +4077,22 @@ __metadata: "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^2.0.5" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jsonschema: "npm:^1.4.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/multichain-network-controller@npm:^3.0.2, @metamask/multichain-network-controller@workspace:packages/multichain-network-controller": +"@metamask/multichain-network-controller@npm:^3.0.3, @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/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" @@ -4219,26 +4105,26 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@solana/addresses": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/multichain-transactions-controller@npm:^7.0.0, @metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller": +"@metamask/multichain-transactions-controller@npm:^7.0.1, @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:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/keyring-api": "npm:^21.5.0" @@ -4252,13 +4138,13 @@ __metadata: "@metamask/snaps-utils": "npm:^11.7.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4275,12 +4161,12 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4307,17 +4193,19 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/jest-when": "npm:^2.7.3" "@types/lodash": "npm:^4.14.191" "@types/node-fetch": "npm:^2.6.12" + "@types/sinon": "npm:^9.0.10" async-mutex: "npm:^0.5.0" cockatiel: "npm:^3.1.2" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" jest-when: "npm:^3.4.2" lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" @@ -4325,8 +4213,8 @@ __metadata: node-fetch: "npm:^2.7.0" reselect: "npm:^5.1.1" sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uri-js: "npm:^4.4.1" @@ -4334,7 +4222,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-enablement-controller@npm:^4.1.0, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": +"@metamask/network-enablement-controller@npm:^4.1.1, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": version: 0.0.0-use.local resolution: "@metamask/network-enablement-controller@workspace:packages/network-enablement-controller" dependencies: @@ -4343,19 +4231,18 @@ __metadata: "@metamask/controller-utils": "npm:^11.18.0" "@metamask/keyring-api": "npm:^21.5.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/multichain-network-controller": "npm:^3.0.2" + "@metamask/multichain-network-controller": "npm:^3.0.3" "@metamask/network-controller": "npm:^29.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4388,20 +4275,22 @@ __metadata: "@metamask/profile-sync-controller": "npm:^27.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" + "@types/lodash": "npm:^4.14.191" "@types/readable-stream": "npm:^2.3.0" "@types/semver": "npm:^7" bignumber.js: "npm:^9.1.2" contentful: "npm:^10.15.0" deepmerge: "npm:^4.2.2" firebase: "npm:^11.2.0" - jest: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" semver: "npm:^7.6.3" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4428,7 +4317,7 @@ __metadata: languageName: node linkType: hard -"@metamask/permission-controller@npm:^12.1.0, @metamask/permission-controller@npm:^12.1.1, @metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@workspace:packages/permission-controller": +"@metamask/permission-controller@npm:^12.2.0, @metamask/permission-controller@workspace:packages/permission-controller": version: 0.0.0-use.local resolution: "@metamask/permission-controller@workspace:packages/permission-controller" dependencies: @@ -4442,14 +4331,14 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4466,13 +4355,13 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4488,35 +4377,17 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/phishing-controller@npm:^15.0.0": - version: 15.0.1 - resolution: "@metamask/phishing-controller@npm:15.0.1" - dependencies: - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/controller-utils": "npm:^11.15.0" - "@metamask/messenger": "npm:^0.3.0" - "@noble/hashes": "npm:^1.8.0" - "@types/punycode": "npm:^2.1.0" - ethereum-cryptography: "npm:^2.1.2" - fastest-levenshtein: "npm:^1.0.16" - punycode: "npm:^2.1.1" - peerDependencies: - "@metamask/transaction-controller": ^61.0.0 - checksum: 10/2f3bc2946f8231256c4a17af8369637f9fc4e3beef31b30b45372059e899fedfa22261cf7b526db62fe607e752e74c63de4a0dea6bd811fae046aa677e4929d0 - languageName: node - linkType: hard - -"@metamask/phishing-controller@npm:^16.2.0, @metamask/phishing-controller@workspace:packages/phishing-controller": +"@metamask/phishing-controller@npm:^16.1.0, @metamask/phishing-controller@npm:^16.3.0, @metamask/phishing-controller@workspace:packages/phishing-controller": version: 0.0.0-use.local resolution: "@metamask/phishing-controller@workspace:packages/phishing-controller" dependencies: @@ -4524,20 +4395,19 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/punycode": "npm:^2.1.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" fastest-levenshtein: "npm:^1.0.16" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" punycode: "npm:^2.1.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4553,14 +4423,13 @@ __metadata: "@metamask/network-controller": "npm:^29.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" fast-json-stable-stringify: "npm:^2.1.0" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4588,12 +4457,12 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4603,7 +4472,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller" dependencies: - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" @@ -4612,17 +4481,16 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^16.0.2" "@metamask/profile-sync-controller": "npm:^27.1.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4649,17 +4517,17 @@ __metadata: "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" siwe: "npm:^2.3.2" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" webextension-polyfill: "npm:^0.12.0" @@ -4699,15 +4567,12 @@ __metadata: "@metamask/controller-utils": "npm:^11.18.0" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" - "@types/sinon": "npm:^9.0.10" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - isomorphic-fetch: "npm:^3.0.0" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4723,11 +4588,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4744,12 +4609,12 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4784,13 +4649,12 @@ __metadata: "@metamask/network-controller": "npm:^29.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4825,15 +4689,15 @@ __metadata: "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/elliptic": "npm:^6" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/json-stable-stringify-without-jsonify": "npm:^1.0.2" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4852,15 +4716,15 @@ __metadata: "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -4877,47 +4741,47 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/messenger": "npm:^0.3.0" - "@metamask/signature-controller": "npm:^39.0.1" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/signature-controller": "npm:^39.0.3" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" languageName: unknown linkType: soft -"@metamask/signature-controller@npm:^39.0.1, @metamask/signature-controller@workspace:packages/signature-controller": +"@metamask/signature-controller@npm:^39.0.3, @metamask/signature-controller@workspace:packages/signature-controller": version: 0.0.0-use.local resolution: "@metamask/signature-controller@workspace:packages/signature-controller" dependencies: - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/approval-controller": "npm:^8.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/eth-sig-util": "npm:^8.2.0" - "@metamask/gator-permissions-controller": "npm:^1.1.2" + "@metamask/gator-permissions-controller": "npm:^2.0.0" "@metamask/keyring-controller": "npm:^25.1.0" "@metamask/logging-controller": "npm:^7.0.1" "@metamask/messenger": "npm:^0.3.0" "@metamask/network-controller": "npm:^29.0.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" jsonschema: "npm:^1.4.1" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -4925,15 +4789,15 @@ __metadata: linkType: soft "@metamask/slip44@npm:^4.3.0": - version: 4.3.0 - resolution: "@metamask/slip44@npm:4.3.0" - checksum: 10/508983a48911f2be8d9de117d390ecfb5b949a6032f5d6c5cc63f7f23302b87468be6ff08dee4881d39e8f5f66b5545eab15e6fc0511acea10fd4c99852a8212 + version: 4.4.0 + resolution: "@metamask/slip44@npm:4.4.0" + checksum: 10/296ac7c578bd35792c7e3942a9a0b7d9d7af76cf98358b97403c1ed483faa3c2fe6c71b1c3f8c7719fbfcf9fc73e5fa8707c89ac277ee9ce6c8bc4c694b2059d languageName: node linkType: hard "@metamask/snaps-controllers@npm:^17.2.0": - version: 17.2.0 - resolution: "@metamask/snaps-controllers@npm:17.2.0" + version: 17.2.1 + resolution: "@metamask/snaps-controllers@npm:17.2.1" dependencies: "@metamask/approval-controller": "npm:^8.0.0" "@metamask/base-controller": "npm:^9.0.0" @@ -4942,16 +4806,16 @@ __metadata: "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" "@metamask/object-multiplex": "npm:^2.1.0" - "@metamask/permission-controller": "npm:^12.1.1" - "@metamask/phishing-controller": "npm:^15.0.0" + "@metamask/permission-controller": "npm:^12.2.0" + "@metamask/phishing-controller": "npm:^16.1.0" "@metamask/post-message-stream": "npm:^10.0.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/snaps-registry": "npm:^4.0.0" "@metamask/snaps-rpc-methods": "npm:^14.1.1" "@metamask/snaps-sdk": "npm:^10.3.0" - "@metamask/snaps-utils": "npm:^11.7.0" + "@metamask/snaps-utils": "npm:^11.7.1" "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.8.1" + "@metamask/utils": "npm:^11.9.0" "@xstate/fsm": "npm:^2.0.0" async-mutex: "npm:^0.5.0" concat-stream: "npm:^2.0.0" @@ -4970,7 +4834,7 @@ __metadata: peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 10/03bfdf0fd7aa11e20b8d1d60cc4a8216175ceb18fb9edb85a313a2ebeb07b5f81059879951551396683862d00d6f368b001f996824db681e9315d6614cf1122f + checksum: 10/867f23054e2c08c0ca67d35a6853a1700ac7f0bab3f7d17b3dbcd10f0043331a5063a6086fa787ee4cb8dfebd4ec54ee9bbfdd1a2b6d7ac75ba31ce2d0957f89 languageName: node linkType: hard @@ -4987,23 +4851,23 @@ __metadata: linkType: hard "@metamask/snaps-rpc-methods@npm:^14.1.1": - version: 14.1.1 - resolution: "@metamask/snaps-rpc-methods@npm:14.1.1" + version: 14.2.0 + resolution: "@metamask/snaps-rpc-methods@npm:14.2.0" dependencies: "@metamask/key-tree": "npm:^10.1.1" - "@metamask/permission-controller": "npm:^12.1.0" + "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.3" - "@metamask/snaps-sdk": "npm:^10.1.0" - "@metamask/snaps-utils": "npm:^11.6.1" + "@metamask/snaps-sdk": "npm:^10.4.0" + "@metamask/snaps-utils": "npm:^12.0.0" "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.8.1" + "@metamask/utils": "npm:^11.9.0" "@noble/hashes": "npm:^1.7.1" async-mutex: "npm:^0.5.0" - checksum: 10/871c50f20e6427bcb14d30648bca2867725cc8ef6df579ef8951481f9919ebed2a7713dd821c94666829e240df6ceeb6181a0203fe18413e20a7ff45b1b29895 + checksum: 10/36264d40ebcca8b7d40d766cfc5dffd4e2afa61ff3bc11ee52af3669043e4428c9845565e78315fb7cb0981efd5d26681244fb292d94c42b059f6a76fc527a26 languageName: node linkType: hard -"@metamask/snaps-sdk@npm:^10.1.0, @metamask/snaps-sdk@npm:^10.3.0, @metamask/snaps-sdk@npm:^10.4.0": +"@metamask/snaps-sdk@npm:^10.3.0, @metamask/snaps-sdk@npm:^10.4.0": version: 10.4.0 resolution: "@metamask/snaps-sdk@npm:10.4.0" dependencies: @@ -5017,22 +4881,21 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-utils@npm:^11.6.1, @metamask/snaps-utils@npm:^11.7.0": - version: 11.7.0 - resolution: "@metamask/snaps-utils@npm:11.7.0" +"@metamask/snaps-utils@npm:^11.7.0, @metamask/snaps-utils@npm:^11.7.1": + version: 11.7.1 + resolution: "@metamask/snaps-utils@npm:11.7.1" dependencies: "@babel/core": "npm:^7.23.2" "@babel/types": "npm:^7.23.0" "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" - "@metamask/permission-controller": "npm:^12.1.1" + "@metamask/permission-controller": "npm:^12.2.0" "@metamask/rpc-errors": "npm:^7.0.3" "@metamask/slip44": "npm:^4.3.0" "@metamask/snaps-registry": "npm:^4.0.0" "@metamask/snaps-sdk": "npm:^10.3.0" "@metamask/superstruct": "npm:^3.2.1" - "@metamask/utils": "npm:^11.8.1" - "@noble/hashes": "npm:^1.7.1" + "@metamask/utils": "npm:^11.9.0" "@scure/base": "npm:^1.1.1" chalk: "npm:^4.1.2" cron-parser: "npm:^4.5.0" @@ -5045,30 +4908,61 @@ __metadata: semver: "npm:^7.5.4" ses: "npm:^1.14.0" validate-npm-package-name: "npm:^5.0.0" - checksum: 10/015102a678e3bc4a6609f29d6818dc47d6c70a798c52d7fde2cc030fa32d7978bd7f7cad18dc4e64be7ef02e2bf6a8dd4419064c0eb3e9eb80a77d0166d147dc - languageName: node - linkType: hard - -"@metamask/stake-sdk@npm:^3.2.1": - version: 3.2.1 - resolution: "@metamask/stake-sdk@npm:3.2.1" - checksum: 10/7404ac54e2bd426158b0ae92a2f4c420ef551d18d8a14293c5760b1da1c48cab88df9a7dcce7133f91bbe7899f6c2016642f0e41e170353b6b9ae4c6423d2ad5 + checksum: 10/4f1ebf14df5eef4344aef8367923176b02f68c58a76c2776aef3015e36f1f889f6463a6b991c78e5810424cf117e20972511a12fcc3436839b0f6100cbb8f09e languageName: node linkType: hard -"@metamask/storage-service@npm:^1.0.0, @metamask/storage-service@workspace:packages/storage-service": - version: 0.0.0-use.local - resolution: "@metamask/storage-service@workspace:packages/storage-service" +"@metamask/snaps-utils@npm:^12.0.0": + version: 12.0.1 + resolution: "@metamask/snaps-utils@npm:12.0.1" dependencies: - "@metamask/auto-changelog": "npm:^3.4.4" + "@babel/core": "npm:^7.23.2" + "@babel/types": "npm:^7.23.0" + "@metamask/key-tree": "npm:^10.1.1" "@metamask/messenger": "npm:^0.3.0" + "@metamask/permission-controller": "npm:^12.2.0" + "@metamask/rpc-errors": "npm:^7.0.3" + "@metamask/slip44": "npm:^4.3.0" + "@metamask/snaps-registry": "npm:^4.0.0" + "@metamask/snaps-sdk": "npm:^10.4.0" + "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.9.0" - "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" - deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + "@scure/base": "npm:^1.1.1" + chalk: "npm:^4.1.2" + cron-parser: "npm:^4.5.0" + fast-deep-equal: "npm:^3.1.3" + fast-json-stable-stringify: "npm:^2.1.0" + fast-xml-parser: "npm:^4.4.1" + luxon: "npm:^3.5.0" + marked: "npm:^12.0.1" + rfdc: "npm:^1.3.0" + semver: "npm:^7.5.4" + ses: "npm:^1.14.0" + validate-npm-package-name: "npm:^5.0.0" + checksum: 10/e4f4ee17b674025c3686ed3e0269643db46c737dab418ce1172a43aa227d66a58f857d2956e8956ea225be1bd23d1c1e101eb22e7bd09ae8a34ddafafd5b8a80 + languageName: node + linkType: hard + +"@metamask/stake-sdk@npm:^3.2.1": + version: 3.2.1 + resolution: "@metamask/stake-sdk@npm:3.2.1" + checksum: 10/7404ac54e2bd426158b0ae92a2f4c420ef551d18d8a14293c5760b1da1c48cab88df9a7dcce7133f91bbe7899f6c2016642f0e41e170353b6b9ae4c6423d2ad5 + languageName: node + linkType: hard + +"@metamask/storage-service@npm:^1.0.0, @metamask/storage-service@workspace:packages/storage-service": + version: 0.0.0-use.local + resolution: "@metamask/storage-service@workspace:packages/storage-service" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.9.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -5084,16 +4978,15 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/polling-controller": "npm:^16.0.2" "@metamask/profile-sync-controller": "npm:^27.1.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -5131,7 +5024,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^62.16.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^62.17.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -5143,7 +5036,7 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^35.0.2" + "@metamask/accounts-controller": "npm:^36.0.0" "@metamask/approval-controller": "npm:^8.0.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" @@ -5163,7 +5056,7 @@ __metadata: "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" "@types/node": "npm:^16.18.54" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" @@ -5172,12 +5065,12 @@ __metadata: eth-method-registry: "npm:^4.0.0" fast-json-patch: "npm:^3.1.1" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - sinon: "npm:^9.2.4" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -5193,29 +5086,29 @@ __metadata: dependencies: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" - "@metamask/assets-controllers": "npm:^99.3.1" + "@metamask/assets-controllers": "npm:^99.4.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" - "@metamask/bridge-controller": "npm:^66.0.0" - "@metamask/bridge-status-controller": "npm:^66.0.0" + "@metamask/bridge-controller": "npm:^67.0.0" + "@metamask/bridge-status-controller": "npm:^67.0.0" "@metamask/controller-utils": "npm:^11.18.0" "@metamask/gas-fee-controller": "npm:^26.0.2" "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.0.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown @@ -5238,17 +5131,17 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.2" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^62.16.0" + "@metamask/transaction-controller": "npm:^62.17.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^27.5.1" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" uuid: "npm:^8.3.2" @@ -5643,21 +5536,30 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^6.0.0, @sinonjs/fake-timers@npm:^6.0.1": - version: 6.0.1 - resolution: "@sinonjs/fake-timers@npm:6.0.1" +"@sinonjs/commons@npm:^3.0.0": + version: 3.0.1 + resolution: "@sinonjs/commons@npm:3.0.1" dependencies: - "@sinonjs/commons": "npm:^1.7.0" - checksum: 10/c7ee19f62bd0ca52553dd5fca9b3921373218c9fed0f02af2f8e5261f65ce9ff0a5e55ca612ded6daf4088a243e905d61bd6dce1c6d325794283b55c71708395 + type-detect: "npm:4.0.8" + checksum: 10/a0af217ba7044426c78df52c23cedede6daf377586f3ac58857c565769358ab1f44ebf95ba04bbe38814fba6e316ca6f02870a009328294fc2c555d0f85a7117 languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^8.0.1": - version: 8.1.0 - resolution: "@sinonjs/fake-timers@npm:8.1.0" +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e + languageName: node + linkType: hard + +"@sinonjs/fake-timers@npm:^6.0.0, @sinonjs/fake-timers@npm:^6.0.1": + version: 6.0.1 + resolution: "@sinonjs/fake-timers@npm:6.0.1" dependencies: "@sinonjs/commons": "npm:^1.7.0" - checksum: 10/da50ddd68411617fcf72d9fb70b621aa2a6d17faa93a2769c7af390c88b40e045f84544db022dd1ac30a6db115d2a0f96473854d4a106b0174351f22d42910ce + checksum: 10/c7ee19f62bd0ca52553dd5fca9b3921373218c9fed0f02af2f8e5261f65ce9ff0a5e55ca612ded6daf4088a243e905d61bd6dce1c6d325794283b55c71708395 languageName: node linkType: hard @@ -5813,16 +5715,16 @@ __metadata: linkType: hard "@tanstack/query-core@npm:^5.62.16": - version: 5.90.18 - resolution: "@tanstack/query-core@npm:5.90.18" - checksum: 10/50435d2db52451970bdf4c12ce47cd6e28b6c4c296034e2910569df36d38c5bcc04b3eb8bca2947ceb5ad82f791954f77a9ea058099227d0885605414c5c7c1f + version: 5.90.20 + resolution: "@tanstack/query-core@npm:5.90.20" + checksum: 10/25e38f4382442bc15e0f6cce8d787e9df8d8822c61d3f3e9427e89e01b1e2506f848292e086dae29aeb55f8ce71b097c34221f3c5eda37fb4a688b5ceca5d1b3 languageName: node linkType: hard -"@tootallnate/once@npm:1": - version: 1.1.2 - resolution: "@tootallnate/once@npm:1.1.2" - checksum: 10/e1fb1bbbc12089a0cb9433dc290f97bddd062deadb6178ce9bcb93bb7c1aecde5e60184bc7065aec42fe1663622a213493c48bbd4972d931aae48315f18e1be9 +"@tootallnate/once@npm:2": + version: 2.0.0 + resolution: "@tootallnate/once@npm:2.0.0" + checksum: 10/ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 languageName: node linkType: hard @@ -5925,7 +5827,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -5957,7 +5859,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.4, @types/babel__traverse@npm:^7.0.6": +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": version: 7.20.6 resolution: "@types/babel__traverse@npm:7.20.6" dependencies: @@ -6034,7 +5936,7 @@ __metadata: languageName: node linkType: hard -"@types/graceful-fs@npm:^4.1.2, @types/graceful-fs@npm:^4.1.3": +"@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" dependencies: @@ -6077,23 +5979,24 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:*": - version: 29.5.12 - resolution: "@types/jest@npm:29.5.12" +"@types/jest@npm:*, @types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" dependencies: expect: "npm:^29.0.0" pretty-format: "npm:^29.0.0" - checksum: 10/312e8dcf92cdd5a5847d6426f0940829bca6fe6b5a917248f3d7f7ef5d85c9ce78ef05e47d2bbabc40d41a930e0e36db2d443d2610a9e3db9062da2d5c904211 + checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b languageName: node linkType: hard -"@types/jest@npm:^27.5.2": - version: 27.5.2 - resolution: "@types/jest@npm:27.5.2" +"@types/jsdom@npm:^20.0.0": + version: 20.0.1 + resolution: "@types/jsdom@npm:20.0.1" dependencies: - jest-matcher-utils: "npm:^27.0.0" - pretty-format: "npm:^27.0.0" - checksum: 10/8608696fbdea81bc9a600d1c5aeb290063357eaa55c0174e7db15087c4f483113b35f8b4c4ae364d2632cfed15a4dd674786254826b946c896de5612c8cb1a26 + "@types/node": "npm:*" + "@types/tough-cookie": "npm:*" + parse5: "npm:^7.0.0" + checksum: 10/15fbb9a0bfb4a5845cf6e795f2fd12400aacfca53b8c7e5bca4a3e5e8fa8629f676327964d64258aefb127d2d8a2be86dad46359efbfca0e8c9c2b790e7f8a88 languageName: node linkType: hard @@ -6209,13 +6112,6 @@ __metadata: languageName: node linkType: hard -"@types/prettier@npm:^2.1.5": - version: 2.7.3 - resolution: "@types/prettier@npm:2.7.3" - checksum: 10/cda84c19acc3bf327545b1ce71114a7d08efbd67b5030b9e8277b347fa57b05178045f70debe1d363ff7efdae62f237260713aafc2d7217e06fc99b048a88497 - languageName: node - linkType: hard - "@types/punycode@npm:^2.1.0": version: 2.1.4 resolution: "@types/punycode@npm:2.1.4" @@ -6272,6 +6168,13 @@ __metadata: languageName: node linkType: hard +"@types/tough-cookie@npm:*": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10/01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482 + languageName: node + linkType: hard + "@types/unzipper@npm:^0.10.10": version: 0.10.11 resolution: "@types/unzipper@npm:0.10.11" @@ -6311,15 +6214,6 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^16.0.0": - version: 16.0.9 - resolution: "@types/yargs@npm:16.0.9" - dependencies: - "@types/yargs-parser": "npm:*" - checksum: 10/8f31cbfcd5c3ac67c27e26026d8b9af0c37770fb2421b661939ba06d136f5a4fa61528a5d0f495d5802fbf1d9244b499e664d8d884e3eb3c36d556fb7c278f18 - languageName: node - linkType: hard - "@types/yargs@npm:^17.0.32, @types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" @@ -6329,139 +6223,138 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.48.0, @typescript-eslint/eslint-plugin@npm:^8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.48.0" +"@typescript-eslint/eslint-plugin@npm:8.54.0, @typescript-eslint/eslint-plugin@npm:^8.48.0": + version: 8.54.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.54.0" dependencies: - "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.48.0" - "@typescript-eslint/type-utils": "npm:8.48.0" - "@typescript-eslint/utils": "npm:8.48.0" - "@typescript-eslint/visitor-keys": "npm:8.48.0" - graphemer: "npm:^1.4.0" - ignore: "npm:^7.0.0" + "@eslint-community/regexpp": "npm:^4.12.2" + "@typescript-eslint/scope-manager": "npm:8.54.0" + "@typescript-eslint/type-utils": "npm:8.54.0" + "@typescript-eslint/utils": "npm:8.54.0" + "@typescript-eslint/visitor-keys": "npm:8.54.0" + ignore: "npm:^7.0.5" natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.1.0" + ts-api-utils: "npm:^2.4.0" peerDependencies: - "@typescript-eslint/parser": ^8.48.0 + "@typescript-eslint/parser": ^8.54.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/c9cd87c72da7bb7f6175fdb53a4c08a26e61a3d9d1024960d193276217b37ca1e8e12328a57751ed9380475e11e198f9715e172126ea7d3b3da9948d225db92b + checksum: 10/8f1c74ac77d7a84ae3f201bb09cb67271662befed036266af1eaa0653d09b545353441640516c1c86e0a94939887d32f0473c61a642488b14d46533742bfbd1b languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.48.0, @typescript-eslint/parser@npm:^8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/parser@npm:8.48.0" +"@typescript-eslint/parser@npm:8.54.0, @typescript-eslint/parser@npm:^8.48.0": + version: 8.54.0 + resolution: "@typescript-eslint/parser@npm:8.54.0" dependencies: - "@typescript-eslint/scope-manager": "npm:8.48.0" - "@typescript-eslint/types": "npm:8.48.0" - "@typescript-eslint/typescript-estree": "npm:8.48.0" - "@typescript-eslint/visitor-keys": "npm:8.48.0" - debug: "npm:^4.3.4" + "@typescript-eslint/scope-manager": "npm:8.54.0" + "@typescript-eslint/types": "npm:8.54.0" + "@typescript-eslint/typescript-estree": "npm:8.54.0" + "@typescript-eslint/visitor-keys": "npm:8.54.0" + debug: "npm:^4.4.3" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/5919642345c79a43e57a85e0e69d1f56b5756b3fdb3586ec6371969604f589adc188338c8f12a787456edc3b38c70586d8209cffcf45e35e5a5ebd497c5f4257 + checksum: 10/d2e09462c9966ef3deeba71d9e41d1d4876c61eea65888c93a3db6fba48b89a2165459c6519741d40e969da05ed98d3f4c87a7f56c5521ab5699743cc315f6cb languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/project-service@npm:8.48.0" +"@typescript-eslint/project-service@npm:8.54.0": + version: 8.54.0 + resolution: "@typescript-eslint/project-service@npm:8.54.0" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.48.0" - "@typescript-eslint/types": "npm:^8.48.0" - debug: "npm:^4.3.4" + "@typescript-eslint/tsconfig-utils": "npm:^8.54.0" + "@typescript-eslint/types": "npm:^8.54.0" + debug: "npm:^4.4.3" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/5853a2f57bf8a26b70c1fe5a906c1890ad4f0fca127218a7805161fc9ad547af97f4a600f32f5acdf2f2312b156affca2bea84af9a433215cbcc2056b6a27c77 + checksum: 10/93f0483f6bbcf7cf776a53a130f7606f597fba67cf111e1897873bf1531efaa96e4851cfd461da0f0cc93afbdb51e47bcce11cf7dd4fb68b7030c7f9f240b92f languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.48.0, @typescript-eslint/scope-manager@npm:^8.1.0": - version: 8.48.0 - resolution: "@typescript-eslint/scope-manager@npm:8.48.0" +"@typescript-eslint/scope-manager@npm:8.54.0, @typescript-eslint/scope-manager@npm:^8.1.0": + version: 8.54.0 + resolution: "@typescript-eslint/scope-manager@npm:8.54.0" dependencies: - "@typescript-eslint/types": "npm:8.48.0" - "@typescript-eslint/visitor-keys": "npm:8.48.0" - checksum: 10/963af7af235e940467504969c565b359ca454a156eba0d5af2e4fd9cca4294947187e1a85107ff05801688ac85b5767d2566414cbef47a03c23f7b46527decca + "@typescript-eslint/types": "npm:8.54.0" + "@typescript-eslint/visitor-keys": "npm:8.54.0" + checksum: 10/3474f3197e8647754393dee62b3145c9de71eaa66c8a68f61c8283aa332141803885db9c96caa6a51f78128ad9ef92f774a90361655e57bd951d5b57eb76f914 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.48.0, @typescript-eslint/tsconfig-utils@npm:^8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.48.0" +"@typescript-eslint/tsconfig-utils@npm:8.54.0, @typescript-eslint/tsconfig-utils@npm:^8.54.0": + version: 8.54.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.54.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/e480cd80498c4119a8c5bc413a22abf4bf365b3674ff95f5513292ede31e4fd8118f50d76a786de702696396a43c0c7a4d0c2ccd1c2c7db61bd941ba74495021 + checksum: 10/e9d6b29538716f007919bfcee94f09b7f8e7d2b684ad43d1a3c8d43afb9f0539c7707f84a34f42054e31c8c056b0ccf06575d89e860b4d34632ffefaefafe1fc languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/type-utils@npm:8.48.0" +"@typescript-eslint/type-utils@npm:8.54.0": + version: 8.54.0 + resolution: "@typescript-eslint/type-utils@npm:8.54.0" dependencies: - "@typescript-eslint/types": "npm:8.48.0" - "@typescript-eslint/typescript-estree": "npm:8.48.0" - "@typescript-eslint/utils": "npm:8.48.0" - debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.1.0" + "@typescript-eslint/types": "npm:8.54.0" + "@typescript-eslint/typescript-estree": "npm:8.54.0" + "@typescript-eslint/utils": "npm:8.54.0" + debug: "npm:^4.4.3" + ts-api-utils: "npm:^2.4.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/dfda42624d534f9fed270bd5c76c9c0bb879cccd3dfbfc2977c84489860fbc204f10bca5c69f3ac856cc4342c12f8947293e7449d3391af289620d7ec79ced0d + checksum: 10/60e92fb32274abd70165ce6f4187e4cffa55416374c63731d7de8fdcfb7a558b4dd48909ff1ad38ac39d2ea1248ec54d6ce38dbc065fd34529a217fc2450d5b1 languageName: node linkType: hard -"@typescript-eslint/types@npm:8.48.0, @typescript-eslint/types@npm:^8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/types@npm:8.48.0" - checksum: 10/cd14a7ecd1cb6af94e059a713357b9521ffab08b2793a7d33abda7006816e77f634d49d1ec6f1b99b47257a605347d691bd02b2b11477c9c328f2a27f52a664f +"@typescript-eslint/types@npm:8.54.0, @typescript-eslint/types@npm:^8.54.0": + version: 8.54.0 + resolution: "@typescript-eslint/types@npm:8.54.0" + checksum: 10/c25cc0bdf90fb150cf6ce498897f43fe3adf9e872562159118f34bd91a9bfab5f720cb1a41f3cdf253b2e840145d7d372089b7cef5156624ef31e98d34f91b31 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.48.0" +"@typescript-eslint/typescript-estree@npm:8.54.0": + version: 8.54.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.54.0" dependencies: - "@typescript-eslint/project-service": "npm:8.48.0" - "@typescript-eslint/tsconfig-utils": "npm:8.48.0" - "@typescript-eslint/types": "npm:8.48.0" - "@typescript-eslint/visitor-keys": "npm:8.48.0" - debug: "npm:^4.3.4" - minimatch: "npm:^9.0.4" - semver: "npm:^7.6.0" + "@typescript-eslint/project-service": "npm:8.54.0" + "@typescript-eslint/tsconfig-utils": "npm:8.54.0" + "@typescript-eslint/types": "npm:8.54.0" + "@typescript-eslint/visitor-keys": "npm:8.54.0" + debug: "npm:^4.4.3" + minimatch: "npm:^9.0.5" + semver: "npm:^7.7.3" tinyglobby: "npm:^0.2.15" - ts-api-utils: "npm:^2.1.0" + ts-api-utils: "npm:^2.4.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10/8ee6b9e98dd72d567b8842a695578b2098bd8cdcf5628d2819407a52b533a5a139ba9a5620976641bc4553144a1b971d75f2df218a7c281fe674df25835e9e22 + checksum: 10/3a545037c6f9319251d3ba44cf7a3216b1372422469e27f7ed3415244ebf42553da1ab4644da42d3f0ae2706a8cad12529ffebcb2e75406f74e3b30b812d010d languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.48.0, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.1.0": - version: 8.48.0 - resolution: "@typescript-eslint/utils@npm:8.48.0" +"@typescript-eslint/utils@npm:8.54.0, @typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0, @typescript-eslint/utils@npm:^8.1.0": + version: 8.54.0 + resolution: "@typescript-eslint/utils@npm:8.54.0" dependencies: - "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.48.0" - "@typescript-eslint/types": "npm:8.48.0" - "@typescript-eslint/typescript-estree": "npm:8.48.0" + "@eslint-community/eslint-utils": "npm:^4.9.1" + "@typescript-eslint/scope-manager": "npm:8.54.0" + "@typescript-eslint/types": "npm:8.54.0" + "@typescript-eslint/typescript-estree": "npm:8.54.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/980b9faeaae0357bd7c002b15ab3bbcb7d5e4558be5df7980cf5221b41570a1a7b7d71ea2fcc8b1387f6c0db948d01468e6dcb31230d6757e28ac2ee5d8be4cf + checksum: 10/9f88a2a7ab3e11aa0ff7f99c0e66a0cf2cba10b640def4c64a4f4ef427fecfb22f28dbe5697535915eb01f6507515ac43e45e0ff384bf82856e3420194d9ffdd languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.48.0": - version: 8.48.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.48.0" +"@typescript-eslint/visitor-keys@npm:8.54.0": + version: 8.54.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.54.0" dependencies: - "@typescript-eslint/types": "npm:8.48.0" + "@typescript-eslint/types": "npm:8.54.0" eslint-visitor-keys: "npm:^4.2.1" - checksum: 10/f9eaff8225b3b00e486e0221bd596b08a3ed463f31fab88221256908f6208c48f745281b7b92e6358d25e1dbdc37c6c2f4b42503403c24b071165bafd9a35d52 + checksum: 10/cca5380ee30250302ee1459e5a0a38de8c16213026dbbff3d167fa7d71d012f31d60ac4483ad45ebd13f2ac963d1ca52dd5f22759a68d4ee57626e421769187a languageName: node linkType: hard @@ -6545,7 +6438,7 @@ __metadata: languageName: node linkType: hard -"abab@npm:^2.0.3, abab@npm:^2.0.5": +"abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" checksum: 10/ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 @@ -6578,13 +6471,13 @@ __metadata: languageName: node linkType: hard -"acorn-globals@npm:^6.0.0": - version: 6.0.0 - resolution: "acorn-globals@npm:6.0.0" +"acorn-globals@npm:^7.0.0": + version: 7.0.1 + resolution: "acorn-globals@npm:7.0.1" dependencies: - acorn: "npm:^7.1.1" - acorn-walk: "npm:^7.1.1" - checksum: 10/72d95e5b5e585f9acd019b993ab8bbba68bb3cbc9d9b5c1ebb3c2f1fe5981f11deababfb4949f48e6262f9c57878837f5958c0cca396f81023814680ca878042 + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" + checksum: 10/2a2998a547af6d0db5f0cdb90acaa7c3cbca6709010e02121fb8b8617c0fbd8bab0b869579903fde358ac78454356a14fadcc1a672ecb97b04b1c2ccba955ce8 languageName: node linkType: hard @@ -6597,23 +6490,16 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^7.1.1": - version: 7.2.0 - resolution: "acorn-walk@npm:7.2.0" - checksum: 10/4d3e186f729474aed3bc3d0df44692f2010c726582655b20a23347bef650867655521c48ada444cb4fda241ee713dcb792da363ec74c6282fa884fb7144171bb - languageName: node - linkType: hard - -"acorn@npm:^7.1.1": - version: 7.4.1 - resolution: "acorn@npm:7.4.1" - bin: - acorn: bin/acorn - checksum: 10/8be2a40714756d713dfb62544128adce3b7102c6eb94bc312af196c2cc4af76e5b93079bd66b05e9ca31b35a9b0ce12171d16bc55f366cafdb794fdab9d753ec +"acorn-walk@npm:^8.0.2": + version: 8.3.4 + resolution: "acorn-walk@npm:8.3.4" + dependencies: + acorn: "npm:^8.11.0" + checksum: 10/871386764e1451c637bb8ab9f76f4995d408057e9909be6fb5ad68537ae3375d85e6a6f170b98989f44ab3ff6c74ad120bc2779a3d577606e7a0cd2b4efcaf77 languageName: node linkType: hard -"acorn@npm:^8.15.0, acorn@npm:^8.2.4": +"acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.8.1": version: 8.15.0 resolution: "acorn@npm:8.15.0" bin: @@ -6868,24 +6754,6 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-jest@npm:27.5.1" - dependencies: - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/babel__core": "npm:^7.1.14" - babel-plugin-istanbul: "npm:^6.1.1" - babel-preset-jest: "npm:^27.5.1" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - slash: "npm:^3.0.0" - peerDependencies: - "@babel/core": ^7.8.0 - checksum: 10/d032823796072b3c269edaa623dd7fe6ecf2f72aff5b003066e7b16ad0ec4068ed04f3f569237183161d28b638936121975014bcb26ae539e669f2bdad5babe6 - languageName: node - linkType: hard - "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -6916,18 +6784,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-plugin-jest-hoist@npm:27.5.1" - dependencies: - "@babel/template": "npm:^7.3.3" - "@babel/types": "npm:^7.3.3" - "@types/babel__core": "npm:^7.0.0" - "@types/babel__traverse": "npm:^7.0.6" - checksum: 10/9e334903433fd92ef9a65ea5c61f7d786238704b1327d9ca227ef40ef7142fba2bb8219bcb9b2d56eaf36ecfbcc50aa1e177db64508438569e98cfd67cce5043 - languageName: node - linkType: hard - "babel-plugin-jest-hoist@npm:^29.6.3": version: 29.6.3 resolution: "babel-plugin-jest-hoist@npm:29.6.3" @@ -6965,18 +6821,6 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:^27.5.1": - version: 27.5.1 - resolution: "babel-preset-jest@npm:27.5.1" - dependencies: - babel-plugin-jest-hoist: "npm:^27.5.1" - babel-preset-current-node-syntax: "npm:^1.0.0" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/251bcea11c18fd9672fec104eadb45b43f117ceeb326fa7345ced778d4c1feab29343cd7a87a1dcfae4997d6c851a8b386d7f7213792da6e23b74f4443a8976d - languageName: node - linkType: hard - "babel-preset-jest@npm:^29.6.3": version: 29.6.3 resolution: "babel-preset-jest@npm:29.6.3" @@ -7177,13 +7021,6 @@ __metadata: languageName: node linkType: hard -"browser-process-hrtime@npm:^1.0.0": - version: 1.0.0 - resolution: "browser-process-hrtime@npm:1.0.0" - checksum: 10/e30f868cdb770b1201afb714ad1575dd86366b6e861900884665fb627109b3cc757c40067d3bfee1ff2a29c835257ea30725a8018a9afd02ac1c24b408b1e45f - languageName: node - linkType: hard - "browserify-aes@npm:^1.2.0": version: 1.2.0 resolution: "browserify-aes@npm:1.2.0" @@ -7212,7 +7049,7 @@ __metadata: languageName: node linkType: hard -"bs-logger@npm:0.x": +"bs-logger@npm:^0.2.6": version: 0.2.6 resolution: "bs-logger@npm:0.2.6" dependencies: @@ -7647,13 +7484,6 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^1.4.0, convert-source-map@npm:^1.6.0": - version: 1.9.0 - resolution: "convert-source-map@npm:1.9.0" - checksum: 10/dc55a1f28ddd0e9485ef13565f8f756b342f9a46c4ae18b843fe3c30c675d058d6a4823eff86d472f187b176f0adf51ea7b69ea38be34be4a63cbbf91b0593c8 - languageName: node - linkType: hard - "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -7738,6 +7568,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 + languageName: node + linkType: hard + "cron-parser@npm:^4.5.0": version: 4.9.0 resolution: "cron-parser@npm:4.9.0" @@ -7758,10 +7605,10 @@ __metadata: languageName: node linkType: hard -"cssom@npm:^0.4.4": - version: 0.4.4 - resolution: "cssom@npm:0.4.4" - checksum: 10/6302c5f9b33a15f5430349f91553dd370f60707b1f2bb2c21954abe307b701d6095da134679fd0891a7814bc98061e1639bd0562d8f70c2dc529918111be8d2b +"cssom@npm:^0.5.0": + version: 0.5.0 + resolution: "cssom@npm:0.5.0" + checksum: 10/b502a315b1ce020a692036cc38cb36afa44157219b80deadfa040ab800aa9321fcfbecf02fd2e6ec87db169715e27978b4ab3701f916461e9cf7808899f23b54 languageName: node linkType: hard @@ -7781,14 +7628,14 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^2.0.0": - version: 2.0.0 - resolution: "data-urls@npm:2.0.0" +"data-urls@npm:^3.0.2": + version: 3.0.2 + resolution: "data-urls@npm:3.0.2" dependencies: - abab: "npm:^2.0.3" - whatwg-mimetype: "npm:^2.3.0" - whatwg-url: "npm:^8.0.0" - checksum: 10/97caf828aac25e25e04ba6869db0f99c75e6859bb5b424ada28d3e7841941ebf08ddff3c1b1bb4585986bd507a5d54c2a716853ea6cb98af877400e637393e71 + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + checksum: 10/033fc3dd0fba6d24bc9a024ddcf9923691dd24f90a3d26f6545d6a2f71ec6956f93462f2cdf2183cc46f10dc01ed3bcb36731a8208456eb1a08147e571fe2a76 languageName: node linkType: hard @@ -7801,15 +7648,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.6, debug@npm:^4.3.7": - version: 4.4.0 - resolution: "debug@npm:4.4.0" +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367 + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad languageName: node linkType: hard @@ -7839,17 +7686,22 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.2.1": - version: 10.4.3 - resolution: "decimal.js@npm:10.4.3" - checksum: 10/de663a7bc4d368e3877db95fcd5c87b965569b58d16cdc4258c063d231ca7118748738df17cd638f7e9dd0be8e34cec08d7234b20f1f2a756a52fc5a38b188d0 +"decimal.js@npm:^10.4.2": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 languageName: node linkType: hard -"dedent@npm:^0.7.0": - version: 0.7.0 - resolution: "dedent@npm:0.7.0" - checksum: 10/87de191050d9a40dd70cad01159a0bcf05ecb59750951242070b6abf9569088684880d00ba92a955b4058804f16eeaf91d604f283929b4f614d181cd7ae633d2 +"dedent@npm:^1.0.0": + version: 1.7.1 + resolution: "dedent@npm:1.7.1" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10/78785ef592e37e0b1ca7a7a5964c8f3dee1abdff46c5bb49864168579c122328f6bb55c769bc7e005046a7381c3372d3859f0f78ab083950fa146e1c24873f4f languageName: node linkType: hard @@ -8012,13 +7864,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^27.5.1": - version: 27.5.1 - resolution: "diff-sequences@npm:27.5.1" - checksum: 10/34d852a13eb82735c39944a050613f952038614ce324256e1c3544948fa090f1ca7f329a4f1f57c31fe7ac982c17068d8915b633e300f040b97708c81ceb26cd - languageName: node - linkType: hard - "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -8058,12 +7903,12 @@ __metadata: languageName: node linkType: hard -"domexception@npm:^2.0.1": - version: 2.0.1 - resolution: "domexception@npm:2.0.1" +"domexception@npm:^4.0.0": + version: 4.0.0 + resolution: "domexception@npm:4.0.0" dependencies: - webidl-conversions: "npm:^5.0.0" - checksum: 10/d638e9cb05c52999f1b2eb87c374b03311ea5b1d69c2f875bc92da73e17db60c12142b45c950228642ff7f845c536b65305483350d080df59003a653da80b691 + webidl-conversions: "npm:^7.0.0" + checksum: 10/4ed443227d2871d76c58d852b2e93c68e0443815b2741348f20881bedee8c1ad4f9bfc5d30c7dec433cd026b57da63407c010260b1682fef4c8847e7181ea43f languageName: node linkType: hard @@ -8123,10 +7968,10 @@ __metadata: languageName: node linkType: hard -"emittery@npm:^0.8.1": - version: 0.8.1 - resolution: "emittery@npm:0.8.1" - checksum: 10/3b882c0bdc3121b4e92b85315f87da0db8e965766d6c7ff70a8f45e0c38ed49d561936650afa32759d8fb320a458bc9e12631799a0a276e9e8a960ae16c1f6f1 +"emittery@npm:^0.13.1": + version: 0.13.1 + resolution: "emittery@npm:0.13.1" + checksum: 10/fbe214171d878b924eedf1757badf58a5dce071cd1fa7f620fa841a0901a80d6da47ff05929d53163105e621ce11a71b9d8acb1148ffe1745e045145f6e69521 languageName: node linkType: hard @@ -8184,6 +8029,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10/62af1307202884349d2867f0aac5c60d8b57102ea0b0e768b16246099512c28e239254ad772d6834e7e14cb1b6f153fc3d0c031934e3183b086c86d3838d874a + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -8911,19 +8763,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^27.5.1": - version: 27.5.1 - resolution: "expect@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - checksum: 10/65152be11e791361bb8f74b2516b6ba83021ac4a280b16575340a7dbb72be7fb51b021119a3f40f309a36b375cfb05d4854d5d7af3c53a293a342afc7f86bdaa - languageName: node - linkType: hard - -"expect@npm:^29.0.0": +"expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -9274,17 +9114,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^3.0.0": - version: 3.0.1 - resolution: "form-data@npm:3.0.1" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - mime-types: "npm:^2.1.12" - checksum: 10/944b40ff63b9cb1ca7a97e70f72104c548e0b0263e3e817e49919015a0d687453086259b93005389896dbffd3777cccea2e67c51f4e827590e5979b14ff91bf7 - languageName: node - linkType: hard - "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -9510,7 +9339,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.1, glob@npm:^7.1.2, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -9548,13 +9377,6 @@ __metadata: languageName: node linkType: hard -"globals@npm:^11.1.0": - version: 11.12.0 - resolution: "globals@npm:11.12.0" - checksum: 10/9f054fa38ff8de8fa356502eb9d2dae0c928217b8b5c8de1f09f5c9b6c8a96d8b9bd3afc49acbcd384a98a81fea713c859e1b09e214c60509517bb8fc2bc13c2 - languageName: node - linkType: hard - "globals@npm:^14.0.0": version: 14.0.0 resolution: "globals@npm:14.0.0" @@ -9610,10 +9432,21 @@ __metadata: languageName: node linkType: hard -"graphemer@npm:^1.4.0": - version: 1.4.0 - resolution: "graphemer@npm:1.4.0" - checksum: 10/6dd60dba97007b21e3a829fab3f771803cc1292977fe610e240ea72afd67e5690ac9eeaafc4a99710e78962e5936ab5a460787c2a1180f1cb0ccfac37d29f897 +"handlebars@npm:^4.7.8": + version: 4.7.8 + resolution: "handlebars@npm:4.7.8" + dependencies: + minimist: "npm:^1.2.5" + neo-async: "npm:^2.6.2" + source-map: "npm:^0.6.1" + uglify-js: "npm:^3.1.4" + wordwrap: "npm:^1.0.0" + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 10/bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 languageName: node linkType: hard @@ -9722,12 +9555,12 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^2.0.1": - version: 2.0.1 - resolution: "html-encoding-sniffer@npm:2.0.1" +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" dependencies: - whatwg-encoding: "npm:^1.0.5" - checksum: 10/70365109cad69ee60376715fe0a56dd9ebb081327bf155cda93b2c276976c79cbedee2b988de6b0aefd0671a5d70597a35796e6e7d91feeb2c0aba46df059630 + whatwg-encoding: "npm:^2.0.0" + checksum: 10/707a812ec2acaf8bb5614c8618dc81e2fb6b4399d03e95ff18b65679989a072f4e919b9bef472039301a1bbfba64063ba4c79ea6e851c653ac9db80dbefe8fe5 languageName: node linkType: hard @@ -9765,14 +9598,14 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^4.0.1": - version: 4.0.1 - resolution: "http-proxy-agent@npm:4.0.1" +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" dependencies: - "@tootallnate/once": "npm:1" + "@tootallnate/once": "npm:2" agent-base: "npm:6" debug: "npm:4" - checksum: 10/2e17f5519f2f2740b236d1d14911ea4be170c67419dc15b05ea9a860a22c5d9c6ff4da270972117067cc2cefeba9df5f7cd5e7818fdc6ae52b6acf2a533e5fdd + checksum: 10/5ee19423bc3e0fd5f23ce991b0755699ad2a46a440ce9cec99e8126bb98448ad3479d2c0ea54be5519db5b19a4ffaa69616bac01540db18506dd4dac3dc418f0 languageName: node linkType: hard @@ -9786,7 +9619,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0": +"https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -9829,7 +9662,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -9868,7 +9701,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^7.0.0": +"ignore@npm:^7.0.5": version: 7.0.5 resolution: "ignore@npm:7.0.5" checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 @@ -10122,13 +9955,6 @@ __metadata: languageName: node linkType: hard -"is-typedarray@npm:^1.0.0": - version: 1.0.0 - resolution: "is-typedarray@npm:1.0.0" - checksum: 10/4b433bfb0f9026f079f4eb3fbaa4ed2de17c9995c3a0b5c800bec40799b4b2a8b4e051b1ada77749deb9ded4ae52fe2096973f3a93ff83df1a5a7184a669478c - languageName: node - linkType: hard - "is-unicode-supported@npm:^0.1.0": version: 0.1.0 resolution: "is-unicode-supported@npm:0.1.0" @@ -10204,7 +10030,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^5.0.4, istanbul-lib-instrument@npm:^5.1.0": +"istanbul-lib-instrument@npm:^5.0.4": version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" dependencies: @@ -10217,6 +10043,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:^6.0.0": + version: 6.0.3 + resolution: "istanbul-lib-instrument@npm:6.0.3" + dependencies: + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 10/aa5271c0008dfa71b6ecc9ba1e801bf77b49dc05524e8c30d58aaf5b9505e0cd12f25f93165464d4266a518c5c75284ecb598fbd89fec081ae77d2c9d3327695 + languageName: node + linkType: hard + "istanbul-lib-report@npm:^3.0.0": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" @@ -10262,60 +10101,60 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-changed-files@npm:27.5.1" +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" execa: "npm:^5.0.0" - throat: "npm:^6.0.1" - checksum: 10/fad21687f899e527bc23b3cabda1b1fa74acb8e17e81bca4d6ca10ab83ebf1d7555f38ba66dda148f97c45b816f941aa4694a09ed0d16a4d7fe3216abf1a222f + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d languageName: node linkType: hard -"jest-circus@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-circus@npm:27.5.1" +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" co: "npm:^4.6.0" - dedent: "npm:^0.7.0" - expect: "npm:^27.5.1" + dedent: "npm:^1.0.0" is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" slash: "npm:^3.0.0" stack-utils: "npm:^2.0.3" - throat: "npm:^6.0.1" - checksum: 10/cf8502d2c7669a89d6d9c309842a6bae1b336335f9a108b0ba3d555dcc635c6cc119d28627a5df455215a8bb04bdcdf18b1fee3441aca39c78c8b10053cd33f7 + checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d languageName: node linkType: hard -"jest-cli@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-cli@npm:27.5.1" +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" dependencies: - "@jest/core": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" import-local: "npm:^3.0.2" - jest-config: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" - prompts: "npm:^2.0.1" - yargs: "npm:^16.2.0" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -10323,56 +10162,45 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 10/527be160786a14f541b3f75e6241da1bd9ba51894fc9f2ba6466dba7f6ffd3a03de02b40d172ad1d29edc725847f7dd4f6dbf71d304d2364b075ec81c9a53224 + checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 languageName: node linkType: hard -"jest-config@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-config@npm:27.5.1" +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.8.0" - "@jest/test-sequencer": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - babel-jest: "npm:^27.5.1" + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" chalk: "npm:^4.0.0" ci-info: "npm:^3.2.0" deepmerge: "npm:^4.2.2" - glob: "npm:^7.1.1" + glob: "npm:^7.1.3" graceful-fs: "npm:^4.2.9" - jest-circus: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-jasmine2: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-runner: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" micromatch: "npm:^4.0.4" parse-json: "npm:^5.2.0" - pretty-format: "npm:^27.5.1" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" strip-json-comments: "npm:^3.1.1" peerDependencies: + "@types/node": "*" ts-node: ">=9.0.0" peerDependenciesMeta: + "@types/node": + optional: true ts-node: optional: true - checksum: 10/63bc2dce50289ff921debedab766daa5122129671c77a9f4137d153a27b29ef77725db15d4809553b687c83495cd7ffefc8eadfd8dfa940d7ea878de57f428c2 - languageName: node - linkType: hard - -"jest-diff@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-diff@npm:27.5.1" - dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/af454f30f33af625832bdb02614e188a41e33ce79086b43f95dbcc515274dd36bf8443b8d0299e22c2416e7591da4321e6bc7f2b0aef56471d1133c6b6833221 + checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b languageName: node linkType: hard @@ -10388,61 +10216,60 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-docblock@npm:27.5.1" +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" dependencies: detect-newline: "npm:^3.0.0" - checksum: 10/65c765c5418986313685b7c49dcd844cd3bc281807a35f778d6ba479246b6ea070cdd98384582a9aed1a0d3ebf94b7fb14a33df5975aaae2eb20dc00281731f4 + checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d languageName: node linkType: hard -"jest-each@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-each@npm:27.5.1" +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" chalk: "npm:^4.0.0" - jest-get-type: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/d73e3c7bbcd3a073e9fa29bd1f200bb9757cbcc568460c1d0971fc21924800f2d3e421219a85e20c54ea2a0129d2da9e2dfc266b6014244c5901f3ca2de7a99e + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda languageName: node linkType: hard -"jest-environment-jsdom@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-environment-jsdom@npm:27.5.1" +"jest-environment-jsdom@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-jsdom@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jsdom: "npm:^16.6.0" - checksum: 10/bc104aef7d7530d0740402aa84ac812138b6d1e51fe58adecce679f82b99340ddab73e5ec68fa079f33f50c9ddec9728fc9f0ddcca2ad6f0b351eed2762cc555 + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10/23bbfc9bca914baef4b654f7983175a4d49b0f515a5094ebcb8f819f28ec186f53c0ba06af1855eac04bab1457f4ea79dae05f70052cf899863e8096daa6e0f5 languageName: node linkType: hard -"jest-environment-node@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-environment-node@npm:27.5.1" +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-mock: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - checksum: 10/0f988330c4f3eec092e3fb37ea753b0c6f702e83cd8f4d770af9c2bf964a70bc45fbd34ec6fdb6d71ce98a778d9f54afd673e63f222e4667fff289e8069dba39 - languageName: node - linkType: hard - -"jest-get-type@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-get-type@npm:27.5.1" - checksum: 10/63064ab70195c21007d897c1157bf88ff94a790824a10f8c890392e7d17eda9c3900513cb291ca1c8d5722cad79169764e9a1279f7c8a9c4cd6e9109ff04bbc0 + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 languageName: node linkType: hard @@ -10453,30 +10280,6 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-haste-map@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@types/graceful-fs": "npm:^4.1.2" - "@types/node": "npm:*" - anymatch: "npm:^3.0.3" - fb-watchman: "npm:^2.0.0" - fsevents: "npm:^2.3.2" - graceful-fs: "npm:^4.2.9" - jest-regex-util: "npm:^27.5.1" - jest-serializer: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" - micromatch: "npm:^4.0.4" - walker: "npm:^1.0.7" - dependenciesMeta: - fsevents: - optional: true - checksum: 10/cbf42e4a3d2b6fc8ad64d732c1bb8a230fe25ad3df7f9f93e8af2950691ef9a5241a9d48c5c88e365744a7467b8cb00ab21c01baee4ee0c2b62acc657782545f - languageName: node - linkType: hard - "jest-haste-map@npm:^29.7.0": version: 29.7.0 resolution: "jest-haste-map@npm:29.7.0" @@ -10513,50 +10316,13 @@ __metadata: languageName: node linkType: hard -"jest-jasmine2@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-jasmine2@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/source-map": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - co: "npm:^4.6.0" - expect: "npm:^27.5.1" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - throat: "npm:^6.0.1" - checksum: 10/052d3c99c36295564a6688ae7e66cfd59997ca9589ccaaa2551d344d84699816a6b8c7bebf3a5f7bcdf691a07f7065c61f4a0770b810e5d887acd21f80a06304 - languageName: node - linkType: hard - -"jest-leak-detector@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-leak-detector@npm:27.5.1" - dependencies: - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/5c9689060960567ddaf16c570d87afa760a461885765d2c71ef4f4857bbc3af1482c34e3cce88e50beefde1bf35e33530b020480752057a7e3dbb1ca0bae359f - languageName: node - linkType: hard - -"jest-matcher-utils@npm:^27.0.0, jest-matcher-utils@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-matcher-utils@npm:27.5.1" +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/037f99878a0515581d7728ed3aed03707810f4da5a1c7ffb9d68a2c6c3180851a6ec40b559af37fbe891dde3ba12552b19e47b8188a27b6c5a53376be6907f32 + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 languageName: node linkType: hard @@ -10572,23 +10338,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-message-util@npm:27.5.1" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^27.5.1" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^27.5.1" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/8fbf39dc25a7ef328dab22efcb3b198cbc788e309bc93e39fdb42b5541dba201c76acf47df476a4ee3d3fc6a6898e77bfc02677c198a98af91db1af0a435ade6 - languageName: node - linkType: hard - "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -10606,13 +10355,14 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-mock@npm:27.5.1" +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - checksum: 10/be9a8777801659227d3bb85317a3aca617542779a290a6a45c9addec8bda29f494a524cb4af96c82b825ecb02171e320dfbfde3e3d9218672f9e38c9fac118f4 + jest-util: "npm:^29.7.0" + checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c languageName: node linkType: hard @@ -10628,13 +10378,6 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-regex-util@npm:27.5.1" - checksum: 10/d45ca7a9543616a34f7f3079337439cf07566e677a096472baa2810e274b9808b76767c97b0a4029b8a5b82b9d256dee28ef9ad4138b2b9e5933f6fac106c418 - languageName: node - linkType: hard - "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -10642,101 +10385,89 @@ __metadata: languageName: node linkType: hard -"jest-resolve-dependencies@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-resolve-dependencies@npm:27.5.1" +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - checksum: 10/c67af97afad1da88f5530317c732bbd1262d1225f6cd7f4e4740a5db48f90ab0bd8564738ac70d1a43934894f9aef62205c1b8f8ee89e5c7a737e6a121ee4c25 + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 languageName: node linkType: hard -"jest-resolve@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-resolve@npm:27.5.1" +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" chalk: "npm:^4.0.0" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" + jest-haste-map: "npm:^29.7.0" jest-pnp-resolver: "npm:^1.2.2" - jest-util: "npm:^27.5.1" - jest-validate: "npm:^27.5.1" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" resolve: "npm:^1.20.0" - resolve.exports: "npm:^1.1.0" + resolve.exports: "npm:^2.0.0" slash: "npm:^3.0.0" - checksum: 10/93659a9d5ec365a9f2fd3fcaa8f799e3bd090318c48890951ca4325e863f4eb778bb7f7e8d1d8495eda4c157ee771d93fb31f37364ce1a36a09f77f1089e52a1 + checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 languageName: node linkType: hard -"jest-runner@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-runner@npm:27.5.1" +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" dependencies: - "@jest/console": "npm:^27.5.1" - "@jest/environment": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" chalk: "npm:^4.0.0" - emittery: "npm:^0.8.1" + emittery: "npm:^0.13.1" graceful-fs: "npm:^4.2.9" - jest-docblock: "npm:^27.5.1" - jest-environment-jsdom: "npm:^27.5.1" - jest-environment-node: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-leak-detector: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-runtime: "npm:^27.5.1" - jest-util: "npm:^27.5.1" - jest-worker: "npm:^27.5.1" - source-map-support: "npm:^0.5.6" - throat: "npm:^6.0.1" - checksum: 10/97bd741f442ebbcebfdb5e8389c0df645448d0b4b634e4128b3387d6fe432cf0f93feb0ecfc3842fed20a35c43c24460ed5dd89d7501ca9e2fdba65e5a4edf37 - languageName: node - linkType: hard - -"jest-runtime@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-runtime@npm:27.5.1" - dependencies: - "@jest/environment": "npm:^27.5.1" - "@jest/fake-timers": "npm:^27.5.1" - "@jest/globals": "npm:^27.5.1" - "@jest/source-map": "npm:^27.5.1" - "@jest/test-result": "npm:^27.5.1" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e + languageName: node + linkType: hard + +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" chalk: "npm:^4.0.0" cjs-module-lexer: "npm:^1.0.0" collect-v8-coverage: "npm:^1.0.0" - execa: "npm:^5.0.0" glob: "npm:^7.1.3" graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-mock: "npm:^27.5.1" - jest-regex-util: "npm:^27.5.1" - jest-resolve: "npm:^27.5.1" - jest-snapshot: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" slash: "npm:^3.0.0" strip-bom: "npm:^4.0.0" - checksum: 10/cc6cdce5bee4bc02935a4671394e19962f3469eeb6e823442ca99e5670fd87f60ed64b7c7156ac13d2799fc44fe9bb806454a3f17c8342bd35e564b1a40e3920 - languageName: node - linkType: hard - -"jest-serializer@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-serializer@npm:27.5.1" - dependencies: - "@types/node": "npm:*" - graceful-fs: "npm:^4.2.9" - checksum: 10/803e03a552278610edc6753c0dd9fa5bb5cd3ca47414a7b2918106efb62b79fd5e9ae785d0a21f12a299fa599fea8acc1fa6dd41283328cee43962cf7df9bb44 + checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 languageName: node linkType: hard @@ -10750,33 +10481,31 @@ __metadata: languageName: node linkType: hard -"jest-snapshot@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-snapshot@npm:27.5.1" +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.7.2" + "@babel/core": "npm:^7.11.6" "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" "@babel/plugin-syntax-typescript": "npm:^7.7.2" - "@babel/traverse": "npm:^7.7.2" - "@babel/types": "npm:^7.0.0" - "@jest/transform": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" - "@types/babel__traverse": "npm:^7.0.4" - "@types/prettier": "npm:^2.1.5" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" babel-preset-current-node-syntax: "npm:^1.0.0" chalk: "npm:^4.0.0" - expect: "npm:^27.5.1" + expect: "npm:^29.7.0" graceful-fs: "npm:^4.2.9" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - jest-haste-map: "npm:^27.5.1" - jest-matcher-utils: "npm:^27.5.1" - jest-message-util: "npm:^27.5.1" - jest-util: "npm:^27.5.1" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" natural-compare: "npm:^1.4.0" - pretty-format: "npm:^27.5.1" - semver: "npm:^7.3.2" - checksum: 10/01b2c70c56980f21fc299fa68a1d1e3a9612f06d2fcdd1cf60f636c3dd427b814efc5f15aacc567e0c3b28fd32129be4a10fca34555f358534fc88e5cee4ffbb + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 languageName: node linkType: hard @@ -10794,20 +10523,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:^27.0.0, jest-util@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-util@npm:27.5.1" - dependencies: - "@jest/types": "npm:^27.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - graceful-fs: "npm:^4.2.9" - picomatch: "npm:^2.2.3" - checksum: 10/ecc7da41769558e57dbde544141ffceb536ee53b663de1e002d4b86784cea500a10f9a7f02e8b804e517aa0e34d3145118734c7e8b5071f9f18a153ede5b062d - languageName: node - linkType: hard - "jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -10822,32 +10537,33 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-validate@npm:27.5.1" +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" dependencies: - "@jest/types": "npm:^27.5.1" + "@jest/types": "npm:^29.6.3" camelcase: "npm:^6.2.0" chalk: "npm:^4.0.0" - jest-get-type: "npm:^27.5.1" + jest-get-type: "npm:^29.6.3" leven: "npm:^3.1.0" - pretty-format: "npm:^27.5.1" - checksum: 10/1fc4d46ecead311a0362bb8ea7767718b682e3d73b65c2bf55cb33722c13bb340e52d20f35d7af38918f8655a78ebbedf3d8a9eaba4ac067883cef006fcf9197 + pretty-format: "npm:^29.7.0" + checksum: 10/8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 languageName: node linkType: hard -"jest-watcher@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-watcher@npm:27.5.1" +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" dependencies: - "@jest/test-result": "npm:^27.5.1" - "@jest/types": "npm:^27.5.1" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" ansi-escapes: "npm:^4.2.1" chalk: "npm:^4.0.0" - jest-util: "npm:^27.5.1" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" string-length: "npm:^4.0.1" - checksum: 10/2c2f6cb4256d5cf90c4ae2d8400d5a40399aea9152c85b8b04c3fe4cbecb65e188462de1267d134a42c69d2ddb13a6e50a8ea1aef809b1e4c8fff7a0019ca2c4 + checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 languageName: node linkType: hard @@ -10860,17 +10576,6 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-worker@npm:27.5.1" - dependencies: - "@types/node": "npm:*" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.0.0" - checksum: 10/06c6e2a84591d9ede704d5022fc13791e8876e83397c89d481b0063332abbb64c0f01ef4ca7de520b35c7a1058556078d6bdc3631376f4e9ffb42316c1a8488e - languageName: node - linkType: hard - "jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" @@ -10883,13 +10588,14 @@ __metadata: languageName: node linkType: hard -"jest@npm:^27.5.1": - version: 27.5.1 - resolution: "jest@npm:27.5.1" +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" dependencies: - "@jest/core": "npm:^27.5.1" + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" import-local: "npm:^3.0.2" - jest-cli: "npm:^27.5.1" + jest-cli: "npm:^29.7.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -10897,7 +10603,7 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 10/a1435098e1885e48d2a46c660176cd34d69bc80fa72966a1ea8781ab6d5355ee514d45cf871d2da2b5a54509979e53d39fbb9b149c94e430127f44ed0d70639c + checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a languageName: node linkType: hard @@ -10973,43 +10679,42 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^16.6.0": - version: 16.7.0 - resolution: "jsdom@npm:16.7.0" +"jsdom@npm:^20.0.0": + version: 20.0.3 + resolution: "jsdom@npm:20.0.3" dependencies: - abab: "npm:^2.0.5" - acorn: "npm:^8.2.4" - acorn-globals: "npm:^6.0.0" - cssom: "npm:^0.4.4" + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" cssstyle: "npm:^2.3.0" - data-urls: "npm:^2.0.0" - decimal.js: "npm:^10.2.1" - domexception: "npm:^2.0.1" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" escodegen: "npm:^2.0.0" - form-data: "npm:^3.0.0" - html-encoding-sniffer: "npm:^2.0.1" - http-proxy-agent: "npm:^4.0.1" - https-proxy-agent: "npm:^5.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.0" - parse5: "npm:6.0.1" - saxes: "npm:^5.0.1" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" + saxes: "npm:^6.0.0" symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^4.0.0" - w3c-hr-time: "npm:^1.0.2" - w3c-xmlserializer: "npm:^2.0.0" - webidl-conversions: "npm:^6.1.0" - whatwg-encoding: "npm:^1.0.5" - whatwg-mimetype: "npm:^2.3.0" - whatwg-url: "npm:^8.5.0" - ws: "npm:^7.4.6" - xml-name-validator: "npm:^3.0.0" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" peerDependencies: canvas: ^2.5.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/c530c04b0e3718769a66e19b0b5c762126658bce384d6743b807a28a9d89beba4ad932e474f570323efe6ce832b3d9a8f94816fd6c4d386416d5ea0b64e07ebc + checksum: 10/a4cdcff5b07eed87da90b146b82936321533b5efe8124492acf7160ebd5b9cf2b3c2435683592bf1cffb479615245756efb6c173effc1906f845a86ed22af985 languageName: node linkType: hard @@ -11080,7 +10785,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:2.x, json5@npm:^2.2.3": +"json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -11235,7 +10940,7 @@ __metadata: languageName: node linkType: hard -"lodash.memoize@npm:4.x": +"lodash.memoize@npm:^4.1.2": version: 4.1.2 resolution: "lodash.memoize@npm:4.1.2" checksum: 10/192b2168f310c86f303580b53acf81ab029761b9bd9caa9506a019ffea5f3363ea98d7e39e7e11e6b9917066c9d36a09a11f6fe16f812326390d8f3a54a1a6da @@ -11249,7 +10954,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.21, lodash@npm:^4.7.0": +"lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: 10/c08619c038846ea6ac754abd6dd29d2568aa705feb69339e836dfa8d8b09abbb2f859371e86863eda41848221f9af43714491467b5b0299122431e202bb0c532 @@ -11337,7 +11042,7 @@ __metadata: languageName: node linkType: hard -"make-error@npm:1.x": +"make-error@npm:^1.3.6": version: 1.3.6 resolution: "make-error@npm:1.3.6" checksum: 10/b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402 @@ -11573,7 +11278,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": +"minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -11593,6 +11298,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.5": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -11768,6 +11480,13 @@ __metadata: languageName: node linkType: hard +"neo-async@npm:^2.6.2": + version: 2.6.2 + resolution: "neo-async@npm:2.6.2" + checksum: 10/1a7948fea86f2b33ec766bc899c88796a51ba76a4afc9026764aedc6e7cde692a09067031e4a1bf6db4f978ccd99e7f5b6c03fe47ad9865c3d4f99050d67e002 + languageName: node + linkType: hard + "nise@npm:^4.0.4": version: 4.1.0 resolution: "nise@npm:4.1.0" @@ -11990,10 +11709,10 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.0": - version: 2.2.16 - resolution: "nwsapi@npm:2.2.16" - checksum: 10/1e5e086cdd4ca4a45f414d37f49bf0ca81d84ed31c6871ac68f531917d2910845db61f77c6d844430dc90fda202d43fce9603024e74038675de95229eb834dba +"nwsapi@npm:^2.2.2": + version: 2.2.23 + resolution: "nwsapi@npm:2.2.23" + checksum: 10/aa4a570039c33d70b51436d1bb533f3e2c33c488ccbe9b09285c46a6cee5ef266fd60103461085c6954ba52460786a8138f042958328c7c1b4763898eb3dadfa languageName: node linkType: hard @@ -12075,7 +11794,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2": +"p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": version: 3.1.0 resolution: "p-limit@npm:3.1.0" dependencies: @@ -12170,10 +11889,12 @@ __metadata: languageName: node linkType: hard -"parse5@npm:6.0.1": - version: 6.0.1 - resolution: "parse5@npm:6.0.1" - checksum: 10/dfb110581f62bd1425725a7c784ae022a24669bd0efc24b58c71fc731c4d868193e2ebd85b74cde2dbb965e4dcf07059b1e651adbec1b3b5267531bd132fdb75 +"parse5@npm:^7.0.0, parse5@npm:^7.1.1": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10/b0e48be20b820c655b138b86fa6fb3a790de6c891aa2aba536524f8027b4dca4fe538f11a0e5cf2f6f847d120dbb9e4822dcaeb933ff1e10850a2ef0154d1d88 languageName: node linkType: hard @@ -12265,7 +11986,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.1": +"picocolors@npm:^1.0.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10/e1cf46bf84886c79055fdfa9dcb3e4711ad259949e3565154b004b260cd356c5d54b31a1437ce9782624bf766272fe6b0154f5f0c744fb7af5d454d2b60db045 @@ -12394,17 +12115,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.0.0, pretty-format@npm:^27.5.1": - version: 27.5.1 - resolution: "pretty-format@npm:27.5.1" - dependencies: - ansi-regex: "npm:^5.0.1" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^17.0.1" - checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 - languageName: node - linkType: hard - "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -12539,6 +12249,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "qs@npm:6.13.0, qs@npm:^6.11.2": version: 6.13.0 resolution: "qs@npm:6.13.0" @@ -12604,13 +12321,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 - languageName: node - linkType: hard - "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -12797,10 +12507,10 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^1.1.0": - version: 1.1.1 - resolution: "resolve.exports@npm:1.1.1" - checksum: 10/de58c30aca30883f0e29910e4ad1b7b9986ec5f69434ef2e957ddbe52d3250e138ddd2688e8cd67909b4ee9bf3437424c718a5962d59edd610f035b861ef8441 +"resolve.exports@npm:^2.0.0": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a languageName: node linkType: hard @@ -12877,17 +12587,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.0": - version: 3.0.2 - resolution: "rimraf@npm:3.0.2" - dependencies: - glob: "npm:^7.1.3" - bin: - rimraf: bin.js - checksum: 10/063ffaccaaaca2cfd0ef3beafb12d6a03dd7ff1260d752d62a6077b5dfff6ae81bea571f655bb6b589d366930ec1bdd285d40d560c0dae9b12f125e54eb743d5 - languageName: node - linkType: hard - "rimraf@npm:^5.0.5": version: 5.0.10 resolution: "rimraf@npm:5.0.10" @@ -12971,12 +12670,12 @@ __metadata: languageName: node linkType: hard -"saxes@npm:^5.0.1": - version: 5.0.1 - resolution: "saxes@npm:5.0.1" +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" dependencies: xmlchars: "npm:^2.2.0" - checksum: 10/148b5f98fdd45df25fa1abef35d72cdf6457ac5aef3b7d59d60f770af09d8cf6e7e3a074197071222441d68670fd3198590aba9985e37c4738af2df2f44d0686 + checksum: 10/97b50daf6ca3a153e89842efa18a862e446248296622b7473c169c84c823ee8a16e4a43bac2f73f11fc8cb9168c73fbb0d73340f26552bac17970e9052367aa9 languageName: node linkType: hard @@ -13015,21 +12714,21 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" +"semver@npm:^6.3.0, semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + checksum: 10/1ef3a85bd02a760c6ef76a45b8c1ce18226de40831e02a00bad78485390b98b6ccaa31046245fc63bba4a47a6a592b6c7eedc65cc47126e60489f9cc1ce3ed7e languageName: node linkType: hard -"semver@npm:^6.3.0, semver@npm:^6.3.1": - version: 6.3.1 - resolution: "semver@npm:6.3.1" +"semver@npm:^7.1.1, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.3": + version: 7.7.3 + resolution: "semver@npm:7.7.3" bin: semver: bin/semver.js - checksum: 10/1ef3a85bd02a760c6ef76a45b8c1ce18226de40831e02a00bad78485390b98b6ccaa31046245fc63bba4a47a6a592b6c7eedc65cc47126e60489f9cc1ce3ed7e + checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 languageName: node linkType: hard @@ -13147,7 +12846,7 @@ __metadata: languageName: node linkType: hard -"shiki@npm:^0.14.1": +"shiki@npm:^0.14.7": version: 0.14.7 resolution: "shiki@npm:0.14.7" dependencies: @@ -13171,7 +12870,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -13317,13 +13016,13 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:^0.5.6": - version: 0.5.21 - resolution: "source-map-support@npm:0.5.21" +"source-map-support@npm:0.5.13": + version: 0.5.13 + resolution: "source-map-support@npm:0.5.13" dependencies: buffer-from: "npm:^1.0.0" source-map: "npm:^0.6.0" - checksum: 10/8317e12d84019b31e34b86d483dd41d6f832f389f7417faf8fc5c75a66a12d9686e47f589a0554a868b8482f037e23df9d040d29387eb16fa14cb85f091ba207 + checksum: 10/d1514a922ac9c7e4786037eeff6c3322f461cd25da34bb9fefb15387b3490531774e6e31d95ab6d5b84a3e139af9c3a570ccaee6b47bd7ea262691ed3a8bc34e languageName: node linkType: hard @@ -13334,13 +13033,6 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.7.3": - version: 0.7.4 - resolution: "source-map@npm:0.7.4" - checksum: 10/a0f7c9b797eda93139842fd28648e868a9a03ea0ad0d9fa6602a0c1f17b7fb6a7dcca00c144476cccaeaae5042e99a285723b1a201e844ad67221bf5d428f1dc - languageName: node - linkType: hard - "spdx-correct@npm:^3.0.0": version: 3.2.0 resolution: "spdx-correct@npm:3.2.0" @@ -13675,16 +13367,6 @@ __metadata: languageName: node linkType: hard -"terminal-link@npm:^2.0.0": - version: 2.1.1 - resolution: "terminal-link@npm:2.1.1" - dependencies: - ansi-escapes: "npm:^4.2.1" - supports-hyperlinks: "npm:^2.0.0" - checksum: 10/ce3d2cd3a438c4a9453947aa664581519173ea40e77e2534d08c088ee6dda449eabdbe0a76d2a516b8b73c33262fedd10d5270ccf7576ae316e3db170ce6562f - languageName: node - linkType: hard - "test-exclude@npm:^6.0.0": version: 6.0.0 resolution: "test-exclude@npm:6.0.0" @@ -13705,13 +13387,6 @@ __metadata: languageName: node linkType: hard -"throat@npm:^6.0.1": - version: 6.0.2 - resolution: "throat@npm:6.0.2" - checksum: 10/acd99f4b7362bcf6dcc517b01517165a00f7270d0c4fe2ca06c73b6217f022f76fb20e8ca98283b25ccb85d97a5f96dbcac5577d60bb0bda1eff92fa8e79fbd7 - languageName: node - linkType: hard - "tinyglobby@npm:^0.2.15": version: 0.2.15 resolution: "tinyglobby@npm:0.2.15" @@ -13745,7 +13420,7 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^4.0.0": +"tough-cookie@npm:^4.1.2": version: 4.1.4 resolution: "tough-cookie@npm:4.1.4" dependencies: @@ -13757,12 +13432,12 @@ __metadata: languageName: node linkType: hard -"tr46@npm:^2.1.0": - version: 2.1.0 - resolution: "tr46@npm:2.1.0" +"tr46@npm:^3.0.0": + version: 3.0.0 + resolution: "tr46@npm:3.0.0" dependencies: punycode: "npm:^2.1.1" - checksum: 10/302b13f458da713b2a6ff779a0c1d27361d369fdca6c19330536d31db61789b06b246968fc879fdac818a92d02643dca1a0f4da5618df86aea4a79fb3243d3f3 + checksum: 10/b09a15886cbfaee419a3469081223489051ce9dca3374dd9500d2378adedbee84a3c73f83bfdd6bb13d53657753fc0d4e20a46bfcd3f1b9057ef528426ad7ce4 languageName: node linkType: hard @@ -13780,45 +13455,52 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.1.0": - version: 2.1.0 - resolution: "ts-api-utils@npm:2.1.0" +"ts-api-utils@npm:^2.4.0": + version: 2.4.0 + resolution: "ts-api-utils@npm:2.4.0" peerDependencies: typescript: ">=4.8.4" - checksum: 10/02e55b49d9617c6eebf8aadfa08d3ca03ca0cd2f0586ad34117fdfc7aa3cd25d95051843fde9df86665ad907f99baed179e7a117b11021417f379e4d2614eacd + checksum: 10/d6b2b3b6caad8d2f4ddc0c3785d22bb1a6041773335a1c71d73a5d67d11d993763fe8e4faefc4a4d03bb42b26c6126bbcf2e34826baed1def5369d0ebad358fa languageName: node linkType: hard -"ts-jest@npm:^27.1.5": - version: 27.1.5 - resolution: "ts-jest@npm:27.1.5" +"ts-jest@npm:^29.2.5": + version: 29.4.6 + resolution: "ts-jest@npm:29.4.6" dependencies: - bs-logger: "npm:0.x" - fast-json-stable-stringify: "npm:2.x" - jest-util: "npm:^27.0.0" - json5: "npm:2.x" - lodash.memoize: "npm:4.x" - make-error: "npm:1.x" - semver: "npm:7.x" - yargs-parser: "npm:20.x" + bs-logger: "npm:^0.2.6" + fast-json-stable-stringify: "npm:^2.1.0" + handlebars: "npm:^4.7.8" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.7.3" + type-fest: "npm:^4.41.0" + yargs-parser: "npm:^21.1.1" peerDependencies: "@babel/core": ">=7.0.0-beta.0 <8" - "@types/jest": ^27.0.0 - babel-jest: ">=27.0.0 <28" - jest: ^27.0.0 - typescript: ">=3.8 <5.0" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <6" peerDependenciesMeta: "@babel/core": optional: true - "@types/jest": + "@jest/transform": + optional: true + "@jest/types": optional: true babel-jest: optional: true esbuild: optional: true + jest-util: + optional: true bin: ts-jest: cli.js - checksum: 10/7675946cefc8c652ec35f2fd600ffb99c8e5db5ac355ceb8317707862c586ee46f7ddd589bd206fa8be2598bc4a87c5a53eb4198af78723f5661c90e32200ba3 + checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e languageName: node linkType: hard @@ -13927,10 +13609,10 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.0.0": - version: 4.25.0 - resolution: "type-fest@npm:4.25.0" - checksum: 10/16ddf51dbfeef45e6f0a139c16f06d6cd05b61be76b048c41e79997f150a66422219d7ec10a2717ab926505402d59b1ddc8560f5f6c245e1b8a35971c2f1b754 +"type-fest@npm:^4.0.0, type-fest@npm:^4.41.0": + version: 4.41.0 + resolution: "type-fest@npm:4.41.0" + checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212 languageName: node linkType: hard @@ -13944,15 +13626,6 @@ __metadata: languageName: node linkType: hard -"typedarray-to-buffer@npm:^3.1.5": - version: 3.1.5 - resolution: "typedarray-to-buffer@npm:3.1.5" - dependencies: - is-typedarray: "npm:^1.0.0" - checksum: 10/7c850c3433fbdf4d04f04edfc751743b8f577828b8e1eb93b95a3bce782d156e267d83e20fb32b3b47813e69a69ab5e9b5342653332f7d21c7d1210661a7a72c - languageName: node - linkType: hard - "typedarray@npm:^0.0.6": version: 0.0.6 resolution: "typedarray@npm:0.0.6" @@ -13969,34 +13642,34 @@ __metadata: languageName: node linkType: hard -"typedoc@npm:^0.24.8": - version: 0.24.8 - resolution: "typedoc@npm:0.24.8" +"typedoc@npm:^0.25.13": + version: 0.25.13 + resolution: "typedoc@npm:0.25.13" dependencies: lunr: "npm:^2.3.9" marked: "npm:^4.3.0" - minimatch: "npm:^9.0.0" - shiki: "npm:^0.14.1" + minimatch: "npm:^9.0.3" + shiki: "npm:^0.14.7" peerDependencies: - typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x bin: typedoc: bin/typedoc - checksum: 10/4f2f92ddde3f70a1a9666507f6bdf6620023599bd2c2a3ed3f8f909f9c28d92594c30ee6ee68f5a248ff70e09623acf1323bad633cb713b9f2e36bbd4fccf683 + checksum: 10/3c82603894b5830c4b027b4f4f9ca70f770b6752c6512a42e780c40cb67fe4c9a144e34a837bb35aab14a125e00a5893e1e6feac1ec86a2add80f46833b279d4 languageName: node linkType: hard "typescript-eslint@npm:^8.48.0": - version: 8.48.0 - resolution: "typescript-eslint@npm:8.48.0" + version: 8.54.0 + resolution: "typescript-eslint@npm:8.54.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.48.0" - "@typescript-eslint/parser": "npm:8.48.0" - "@typescript-eslint/typescript-estree": "npm:8.48.0" - "@typescript-eslint/utils": "npm:8.48.0" + "@typescript-eslint/eslint-plugin": "npm:8.54.0" + "@typescript-eslint/parser": "npm:8.54.0" + "@typescript-eslint/typescript-estree": "npm:8.54.0" + "@typescript-eslint/utils": "npm:8.54.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10/9be54df60faf3b5a6d255032b4478170b6f64e38b8396475a2049479d1e3c1f5a23a18bb4d2d6ff685ef92ff8f2af28215772fe33b48148a8cf83a724d0778d1 + checksum: 10/21b1a27fd44716df8d2c7bac4ebd0caef196a04375fff7919dc817066017b6b8700f1e242bd065a26ac7ce0505b7a588626099e04a28142504ed4f0aae8bffb1 languageName: node linkType: hard @@ -14020,6 +13693,15 @@ __metadata: languageName: node linkType: hard +"uglify-js@npm:^3.1.4": + version: 3.19.3 + resolution: "uglify-js@npm:3.19.3" + bin: + uglifyjs: bin/uglifyjs + checksum: 10/6b9639c1985d24580b01bb0ab68e78de310d38eeba7db45bec7850ab4093d8ee464d80ccfaceda9c68d1c366efbee28573b52f95e69ac792354c145acd380b11 + languageName: node + linkType: hard + "ulid@npm:^2.3.0": version: 2.3.0 resolution: "ulid@npm:2.3.0" @@ -14160,14 +13842,14 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^8.1.0": - version: 8.1.1 - resolution: "v8-to-istanbul@npm:8.1.1" +"v8-to-istanbul@npm:^9.0.1": + version: 9.3.0 + resolution: "v8-to-istanbul@npm:9.3.0" dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.12" "@types/istanbul-lib-coverage": "npm:^2.0.1" - convert-source-map: "npm:^1.6.0" - source-map: "npm:^0.7.3" - checksum: 10/db5469f133a7cfb7680a28ddfb31aad2cc64f282fa7cf0c8e91f91bfd542bf61597260282be28c9648f0f2114963a24b273ed92af9a5cad6cb629c708ca72f8e + convert-source-map: "npm:^2.0.0" + checksum: 10/fb1d70f1176cb9dc46cabbb3fd5c52c8f3e8738b61877b6e7266029aed0870b04140e3f9f4550ac32aebcfe1d0f38b0bac57e1e8fb97d68fec82f2b416148166 languageName: node linkType: hard @@ -14216,25 +13898,16 @@ __metadata: languageName: node linkType: hard -"w3c-hr-time@npm:^1.0.2": - version: 1.0.2 - resolution: "w3c-hr-time@npm:1.0.2" - dependencies: - browser-process-hrtime: "npm:^1.0.0" - checksum: 10/03851d90c236837c24c2983f5a8806a837c6515b21d52e5f29776b07cc08695779303d481454d768308489f00dd9d3232d595acaa5b2686d199465a4d9f7b283 - languageName: node - linkType: hard - -"w3c-xmlserializer@npm:^2.0.0": - version: 2.0.0 - resolution: "w3c-xmlserializer@npm:2.0.0" +"w3c-xmlserializer@npm:^4.0.0": + version: 4.0.0 + resolution: "w3c-xmlserializer@npm:4.0.0" dependencies: - xml-name-validator: "npm:^3.0.0" - checksum: 10/400c18b75ce6af269168f964e7d1eb196a7422e134032906540c69d83b802f38dc64e18fc259c02966a334687483f416398d2ad7ebe9d19ab434a7a0247c71c3 + xml-name-validator: "npm:^4.0.0" + checksum: 10/9a00c412b5496f4f040842c9520bc0aaec6e0c015d06412a91a723cd7d84ea605ab903965f546b4ecdb3eae267f5145ba08565222b1d6cb443ee488cda9a0aee languageName: node linkType: hard -"walker@npm:^1.0.7, walker@npm:^1.0.8": +"walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" dependencies: @@ -14273,17 +13946,10 @@ __metadata: languageName: node linkType: hard -"webidl-conversions@npm:^5.0.0": - version: 5.0.0 - resolution: "webidl-conversions@npm:5.0.0" - checksum: 10/cea864dd9cf1f2133d82169a446fb94427ba089e4676f5895273ea085f165649afe587ae3f19f2f0370751a724bba2d96e9956d652b3e41ac1feaaa4376e2d70 - languageName: node - linkType: hard - -"webidl-conversions@npm:^6.1.0": - version: 6.1.0 - resolution: "webidl-conversions@npm:6.1.0" - checksum: 10/4454b73060a6d83f7ec1f1db24c480b7ecda33880306dd32a3d62d85b36df4789a383489f1248387e5451737dca17054b8cbf2e792ba89e49d76247f0f4f6380 +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10/4c4f65472c010eddbe648c11b977d048dd96956a625f7f8b9d64e1b30c3c1f23ea1acfd654648426ce5c743c2108a5a757c0592f02902cf7367adb7d14e67721 languageName: node linkType: hard @@ -14305,12 +13971,12 @@ __metadata: languageName: node linkType: hard -"whatwg-encoding@npm:^1.0.5": - version: 1.0.5 - resolution: "whatwg-encoding@npm:1.0.5" +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" dependencies: - iconv-lite: "npm:0.4.24" - checksum: 10/5be4efe111dce29ddee3448d3915477fcc3b28f991d9cf1300b4e50d6d189010d47bca2f51140a844cf9b726e8f066f4aee72a04d687bfe4f2ee2767b2f5b1e6 + iconv-lite: "npm:0.6.3" + checksum: 10/162d712d88fd134a4fe587e53302da812eb4215a1baa4c394dfd86eff31d0a079ff932c05233857997de07481093358d6e7587997358f49b8a580a777be22089 languageName: node linkType: hard @@ -14321,10 +13987,20 @@ __metadata: languageName: node linkType: hard -"whatwg-mimetype@npm:^2.3.0": - version: 2.3.0 - resolution: "whatwg-mimetype@npm:2.3.0" - checksum: 10/3582c1d74d708716013433bbab45cb9b31ef52d276adfbe2205d948be1ec9bb1a4ac05ce6d9045f3acc4104489e1344c857b14700002385a4b997a5673ff6416 +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10/96f9f628c663c2ae05412c185ca81b3df54bcb921ab52fe9ebc0081c1720f25d770665401eb2338ab7f48c71568133845638e18a81ed52ab5d4dcef7d22b40ef + languageName: node + linkType: hard + +"whatwg-url@npm:^11.0.0": + version: 11.0.0 + resolution: "whatwg-url@npm:11.0.0" + dependencies: + tr46: "npm:^3.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10/dfcd51c6f4bfb54685528fb10927f3fd3d7c809b5671beef4a8cdd7b1408a7abf3343a35bc71dab83a1424f1c1e92cc2700d7930d95d231df0fac361de0c7648 languageName: node linkType: hard @@ -14338,17 +14014,6 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^8.0.0, whatwg-url@npm:^8.5.0": - version: 8.7.0 - resolution: "whatwg-url@npm:8.7.0" - dependencies: - lodash: "npm:^4.7.0" - tr46: "npm:^2.1.0" - webidl-conversions: "npm:^6.1.0" - checksum: 10/512a8b2703dffbf13a9a247bf2fb27c3048a3ceb5ece09f88b737c8260afaba4b2f6775c2f1cfc29c2ba4859f2454a9de73fac08e239b00ae2b42cd6b8bb0d35 - languageName: node - linkType: hard - "which@npm:^1.2.14": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -14400,6 +14065,13 @@ __metadata: languageName: node linkType: hard +"wordwrap@npm:^1.0.0": + version: 1.0.0 + resolution: "wordwrap@npm:1.0.0" + checksum: 10/497d40beb2bdb08e6d38754faa17ce20b0bf1306327f80cb777927edb23f461ee1f6bc659b3c3c93f26b08e1cf4b46acc5bae8fda1f0be3b5ab9a1a0211034cd + languageName: node + linkType: hard + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" @@ -14429,18 +14101,6 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^3.0.0": - version: 3.0.3 - resolution: "write-file-atomic@npm:3.0.3" - dependencies: - imurmurhash: "npm:^0.1.4" - is-typedarray: "npm:^1.0.0" - signal-exit: "npm:^3.0.2" - typedarray-to-buffer: "npm:^3.1.5" - checksum: 10/0955ab94308b74d32bc252afe69d8b42ba4b8a28b8d79f399f3f405969f82623f981e35d13129a52aa2973450f342107c06d86047572637584e85a1c0c246bf3 - languageName: node - linkType: hard - "write-file-atomic@npm:^4.0.2": version: 4.0.2 resolution: "write-file-atomic@npm:4.0.2" @@ -14476,7 +14136,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^7.4.6, ws@npm:^7.5.10": +"ws@npm:^7.5.10": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: @@ -14491,6 +14151,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.11.0": + version: 8.19.0 + resolution: "ws@npm:8.19.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/26e4901e93abaf73af9f26a93707c95b4845e91a7a347ec8c569e6e9be7f9df066f6c2b817b2d685544e208207898a750b78461e6e8d810c11a370771450c31b + languageName: node + linkType: hard + "xhr2@npm:0.2.1": version: 0.2.1 resolution: "xhr2@npm:0.2.1" @@ -14498,10 +14173,10 @@ __metadata: languageName: node linkType: hard -"xml-name-validator@npm:^3.0.0": - version: 3.0.0 - resolution: "xml-name-validator@npm:3.0.0" - checksum: 10/24f5d38c777ad9239dfe99c4ca3cd155415b65ac583785d1514e04b9f86d6d09eaff983ed373e7a779ceefd1fca0fd893f2fc264999e9aeaac36b6e1afc397ed +"xml-name-validator@npm:^4.0.0": + version: 4.0.0 + resolution: "xml-name-validator@npm:4.0.0" + checksum: 10/f9582a3f281f790344a471c207516e29e293c6041b2c20d84dd6e58832cd7c19796c47e108fd4fd4b164a5e72ad94f2268f8ace8231cde4a2c6428d6aa220f92 languageName: node linkType: hard @@ -14563,7 +14238,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:20.x, yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": +"yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 10/0188f430a0f496551d09df6719a9132a3469e47fe2747208b1dd0ab2bb0c512a95d0b081628bbca5400fb20dbf2fabe63d22badb346cecadffdd948b049f3fcc @@ -14577,7 +14252,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: